diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21b539b7..6139ded0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ['26.1'] + xcode: ['26.2'] config: ['debug', 'release'] runs-on: macos-26 steps: @@ -28,7 +28,7 @@ jobs: name: Examples strategy: matrix: - xcode: ['26.1'] + xcode: ['26.2'] config: ['debug'] scheme: ['Reminders', 'CaseStudies', 'SyncUps'] runs-on: macos-26 diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 1a1a5c6f..ae9e0de0 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -3,15 +3,27 @@ import SQLiteData import SwiftUI struct CountersListView: View { - @FetchAll var counters: [Counter] + @FetchAll( + Counter + .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + Row.Columns(counter: $0, isShared: $1.isShared.ifnull(false)) + } + ) var rows @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultSyncEngine) var syncEngine + + @Selection struct Row { + let counter: Counter + let isShared: Bool + } var body: some View { List { - if !counters.isEmpty { + if !rows.isEmpty { Section { - ForEach(counters) { counter in - CounterRow(counter: counter) + ForEach(rows, id: \.counter.id) { row in + CounterRow(row: row) .buttonStyle(.borderless) } .onDelete { indexSet in @@ -24,10 +36,14 @@ struct CountersListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { - withErrorReporting { - try database.write { db in - try Counter.insert { Counter.Draft() } + Task { + withErrorReporting { + try database.write { db in + try Counter.insert { + Counter.Draft() + } .execute(db) + } } } } @@ -39,7 +55,7 @@ struct CountersListView: View { withErrorReporting { try database.write { db in for index in indexSet { - try Counter.find(counters[index].id).delete() + try Counter.find(rows[index].counter.id).delete() .execute(db) } } @@ -48,7 +64,7 @@ struct CountersListView: View { } struct CounterRow: View { - let counter: Counter + let row: CountersListView.Row @State var sharedRecord: SharedRecord? @Dependency(\.defaultDatabase) var database @Dependency(\.defaultSyncEngine) var syncEngine @@ -56,7 +72,10 @@ struct CounterRow: View { var body: some View { VStack { HStack { - Text("\(counter.count)") + if row.isShared { + Image(systemName: "network") + } + Text("\(row.counter.count)") Button("-") { decrementButtonTapped() } @@ -78,7 +97,7 @@ struct CounterRow: View { func shareButtonTapped() { Task { - sharedRecord = try await syncEngine.share(record: counter) { share in + sharedRecord = try await syncEngine.share(record: row.counter) { share in share[CKShare.SystemFieldKey.title] = "Join my counter!" } } @@ -87,7 +106,7 @@ struct CounterRow: View { func decrementButtonTapped() { withErrorReporting { try database.write { db in - try Counter.find(counter.id).update { + try Counter.find(row.counter.id).update { $0.count -= 1 } .execute(db) @@ -98,7 +117,7 @@ struct CounterRow: View { func incrementButtonTapped() { withErrorReporting { try database.write { db in - try Counter.find(counter.id).update { + try Counter.find(row.counter.id).update { $0.count += 1 } .execute(db) @@ -110,6 +129,7 @@ struct CounterRow: View { #Preview { let _ = try! prepareDependencies { try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() } NavigationStack { CountersListView() diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 03eaf63c..b4194cc1 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -47,3 +47,16 @@ extension DependencyValues { } private let logger = Logger(subsystem: "CloudKitDemo", category: "Database") + +#if DEBUG + extension DatabaseWriter { + func seedSampleData() throws { + try write { db in + try db.seed { + Counter.Draft(count: 24) + Counter.Draft(count: 1729) + } + } + } + } +#endif diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 95bef7bd..9348435a 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", - "version" : "7.8.0" + "revision" : "aa0079aeb82a4bf00324561a40bffe68c6fe1c26", + "version" : "7.9.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "database-function-vars", - "revision" : "7c65021d46fc1632e357125f3156e22db23a9849" + "revision" : "862802b5a66aec04219b7c2a10e06a5681da86ee", + "version" : "0.27.0" } }, { diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index b64d8110..164443b7 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -84,7 +84,7 @@ class RemindersDetailModel: HashableObject { private func updateQuery() async { await withErrorReporting { - try await $reminderRows.load(remindersQuery, animation: .default) + _ = try await $reminderRows.load(remindersQuery, animation: .default) } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 971a8979..11080ec3 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -144,9 +144,7 @@ class RemindersListsModel { #if DEBUG func seedDatabaseButtonTapped() { withErrorReporting { - try database.write { db in - try db.seedSampleData() - } + try database.seedSampleData() } } #endif @@ -437,7 +435,8 @@ private struct ReminderGridCell: View { #Preview { let _ = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() } NavigationStack { RemindersListsView(model: RemindersListsModel()) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 55fe3a22..4956f2cc 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -140,6 +140,7 @@ func appDatabase() throws -> any DatabaseWriter { db.add(function: $handleReminderStatusUpdate) #if DEBUG db.trace(options: .profile) { + guard !SyncEngine.isSynchronizing else { return } switch context { case .live: logger.debug("\($0.expandedDescription)") @@ -363,10 +364,6 @@ func appDatabase() throws -> any DatabaseWriter { } ) .execute(db) - - if context != .live { - try db.seedSampleData() - } } return database @@ -410,134 +407,136 @@ nonisolated func createDefaultRemindersList() { nonisolated private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG - extension Database { + extension DatabaseWriter { func seedSampleData() throws { @Dependency(\.date.now) var now @Dependency(\.uuid) var uuid - var remindersListIDs: [UUID] = [] - for _ in 0...2 { - remindersListIDs.append(uuid()) - } - var reminderIDs: [UUID] = [] - for _ in 0...10 { - reminderIDs.append(uuid()) - } - try seed { - RemindersList( - id: remindersListIDs[0], - color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), - title: "Personal" - ) - RemindersList( - id: remindersListIDs[1], - color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), - title: "Family" - ) - RemindersList( - id: remindersListIDs[2], - color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), - title: "Business" - ) - Reminder( - id: reminderIDs[0], - notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: remindersListIDs[0], - title: "Groceries" - ) - Reminder( - id: reminderIDs[1], - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: remindersListIDs[0], - title: "Haircut" - ) - Reminder( - id: reminderIDs[2], - dueDate: now, - notes: "Ask about diet", - priority: .high, - remindersListID: remindersListIDs[0], - title: "Doctor appointment" - ) - Reminder( - id: reminderIDs[3], - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), - remindersListID: remindersListIDs[0], - status: .completed, - title: "Take a walk" - ) - Reminder( - id: reminderIDs[4], - dueDate: now, - remindersListID: remindersListIDs[0], - title: "Buy concert tickets" - ) - Reminder( - id: reminderIDs[5], - dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - isFlagged: true, - priority: .high, - remindersListID: remindersListIDs[1], - title: "Pick up kids from school" - ) - Reminder( - id: reminderIDs[6], - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - priority: .low, - remindersListID: remindersListIDs[1], - status: .completed, - title: "Get laundry" - ) - Reminder( - id: reminderIDs[7], - dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), - priority: .high, - remindersListID: remindersListIDs[1], - status: .incomplete, - title: "Take out trash" - ) - Reminder( - id: reminderIDs[8], - dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - notes: """ - Status of tax return - Expenses for next year - Changing payroll company - """, - remindersListID: remindersListIDs[2], - title: "Call accountant" - ) - Reminder( - id: reminderIDs[9], - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - priority: .medium, - remindersListID: remindersListIDs[2], - status: .completed, - title: "Send weekly emails" - ) - Reminder( - id: reminderIDs[10], - dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - remindersListID: remindersListIDs[2], - status: .incomplete, - title: "Prepare for WWDC" - ) - let tagIDs = ["car", "kids", "someday", "optional", "social", "night", "adulting"] - for tagID in tagIDs { - Tag(title: tagID) + try write { db in + var remindersListIDs: [UUID] = [] + for _ in 0...2 { + remindersListIDs.append(uuid()) + } + var reminderIDs: [UUID] = [] + for _ in 0...10 { + reminderIDs.append(uuid()) + } + try db.seed { + RemindersList( + id: remindersListIDs[0], + color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + title: "Personal" + ) + RemindersList( + id: remindersListIDs[1], + color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + title: "Family" + ) + RemindersList( + id: remindersListIDs[2], + color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + title: "Business" + ) + Reminder( + id: reminderIDs[0], + notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", + remindersListID: remindersListIDs[0], + title: "Groceries" + ) + Reminder( + id: reminderIDs[1], + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + remindersListID: remindersListIDs[0], + title: "Haircut" + ) + Reminder( + id: reminderIDs[2], + dueDate: now, + notes: "Ask about diet", + priority: .high, + remindersListID: remindersListIDs[0], + title: "Doctor appointment" + ) + Reminder( + id: reminderIDs[3], + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), + remindersListID: remindersListIDs[0], + status: .completed, + title: "Take a walk" + ) + Reminder( + id: reminderIDs[4], + dueDate: now, + remindersListID: remindersListIDs[0], + title: "Buy concert tickets" + ) + Reminder( + id: reminderIDs[5], + dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + priority: .high, + remindersListID: remindersListIDs[1], + title: "Pick up kids from school" + ) + Reminder( + id: reminderIDs[6], + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + priority: .low, + remindersListID: remindersListIDs[1], + status: .completed, + title: "Get laundry" + ) + Reminder( + id: reminderIDs[7], + dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), + priority: .high, + remindersListID: remindersListIDs[1], + status: .incomplete, + title: "Take out trash" + ) + Reminder( + id: reminderIDs[8], + dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + remindersListID: remindersListIDs[2], + title: "Call accountant" + ) + Reminder( + id: reminderIDs[9], + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + priority: .medium, + remindersListID: remindersListIDs[2], + status: .completed, + title: "Send weekly emails" + ) + Reminder( + id: reminderIDs[10], + dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), + remindersListID: remindersListIDs[2], + status: .incomplete, + title: "Prepare for WWDC" + ) + let tagIDs = ["car", "kids", "someday", "optional", "social", "night", "adulting"] + for tagID in tagIDs { + Tag(title: tagID) + } + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[2]) + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[3]) + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[6]) + ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[2]) + ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[3]) + ReminderTag.Draft(reminderID: reminderIDs[2], tagID: tagIDs[6]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[0]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[1]) + ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[10], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[5]) } - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[2]) - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[3]) - ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[6]) - ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[2]) - ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[3]) - ReminderTag.Draft(reminderID: reminderIDs[2], tagID: tagIDs[6]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[0]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[1]) - ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[10], tagID: tagIDs[4]) - ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[5]) } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 89d3f37b..be22878f 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -250,9 +250,9 @@ struct SearchRemindersView: View { #Preview { @Previewable @State var searchText = "take" let _ = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() } - NavigationStack { List { if !searchText.isEmpty { diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index c40b777a..ae97a2b4 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -68,6 +68,7 @@ struct TagsView: View { Button("Save") { saveButtonTapped() } + Button("Cancel", role: .cancel) {} } .toolbar { ToolbarItem { @@ -102,8 +103,11 @@ struct TagsView: View { .where { $0.title.eq(existingTagTitle) } .execute(db) } else { - try Tag.insert(or: .ignore) { tag } - .execute(db) + try Tag.insert { + tag + } onConflictDoUpdate: { _ in + } + .execute(db) } } selectedTags.append(tag) @@ -163,8 +167,8 @@ private struct TagView: View { #Preview { @Previewable @State var tags: [Tag] = [] let _ = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() } - TagsView(selectedTags: $tags) } diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 03f45434..a73dc220 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -12,6 +12,7 @@ import Testing .dependency(\.uuid, .incrementing), .dependencies { try $0.bootstrapDatabase() + try $0.defaultDatabase.seedSampleData() try await $0.defaultSyncEngine.sendChanges() }, .snapshots(record: .missing) diff --git a/Examples/SyncUpTests/Internal.swift b/Examples/SyncUpTests/Internal.swift index 4db25057..5d9dec9c 100644 --- a/Examples/SyncUpTests/Internal.swift +++ b/Examples/SyncUpTests/Internal.swift @@ -3,35 +3,37 @@ import SQLiteData @testable import SyncUps -extension Database { - func seed() throws { - try seed { - SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") +extension DatabaseWriter { + func seedForTests() throws { + try write { db in + try db.seed { + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") - for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: UUID(1)) - } - for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(2)) - } - for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(3)) - } + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: UUID(1)) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(2)) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(3)) + } - Meeting.Draft( - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: UUID(1), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ - deserunt mollit anim id est laborum. - """ - ) + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: UUID(1), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) + } } } } diff --git a/Examples/SyncUpTests/SyncUpFormTests.swift b/Examples/SyncUpTests/SyncUpFormTests.swift index 81eb3dc1..df6b6c24 100644 --- a/Examples/SyncUpTests/SyncUpFormTests.swift +++ b/Examples/SyncUpTests/SyncUpFormTests.swift @@ -6,12 +6,11 @@ import Testing @testable import SyncUps +@MainActor @Suite( .dependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.write { db in - try db.seed() - } + try await $0.defaultDatabase.seedForTests() $0.uuid = .incrementing } ) diff --git a/Examples/SyncUps/App.swift b/Examples/SyncUps/App.swift index fe5426f7..dffcd2bb 100644 --- a/Examples/SyncUps/App.swift +++ b/Examples/SyncUps/App.swift @@ -77,6 +77,9 @@ struct AppView: View { } #Preview("Happy path") { - let _ = try! prepareDependencies { try $0.bootstrapDatabase() } + let _ = try! prepareDependencies { + try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() + } AppView(model: AppModel()) } diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index a3063f5b..b806af40 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -152,35 +152,37 @@ extension DependencyValues { private let logger = Logger(subsystem: "SyncUps", category: "Database") #if DEBUG - extension Database { + extension DatabaseWriter { func seedSampleData() throws { - try seed { - SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") + try write { db in + try db.seed { + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") - for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: UUID(1)) - } - for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(2)) - } - for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(3)) - } + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: UUID(1)) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(2)) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(3)) + } - Meeting.Draft( - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: UUID(1), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ - deserunt mollit anim id est laborum. - """ - ) + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: UUID(1), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) + } } } } diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 50accb52..e5b305c1 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -273,8 +273,9 @@ struct MeetingView: View { #Preview { let syncUp = try! prepareDependencies { try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() return try $0.defaultDatabase.read { db in - try SyncUp.limit(1).fetchOne(db)! + try SyncUp.fetchOne(db)! } } NavigationStack { diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index 0a919bca..7630c792 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -34,9 +34,7 @@ final class SyncUpsListModel { #if DEBUG func seedDatabase() { withErrorReporting { - try database.write { db in - try db.seedSampleData() - } + try database.seedSampleData() } } #endif @@ -156,6 +154,7 @@ private struct SeedDatabaseTip: Tip { #Preview { let _ = try! prepareDependencies { try $0.bootstrapDatabase() + try? $0.defaultDatabase.seedSampleData() } NavigationStack { SyncUpsList(model: SyncUpsListModel()) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 6e9eacd9..2c64af7b 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -52,7 +52,7 @@ if isTesting { queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue } self.init(queryOutput: queryOutput) } @@ -94,7 +94,7 @@ if isTesting { queryOutput._recordChangeTag = coder - .decodeObject(of: NSString.self, forKey: "_recordChangeTag") as? String + .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue } self.init(queryOutput: queryOutput) } @@ -366,7 +366,7 @@ private struct Unbindable: Error {} extension CKRecord { - package var _recordChangeTag: String? { + package var _recordChangeTag: Int? { get { self[#function] } set { self[#function] = newValue } } diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index e76ba671..d36306c8 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -119,12 +119,13 @@ } let recordName = record.recordName let lastKnownServerRecord = try await { - let lastKnownServerRecord = try await metadatabase.read { db in - try SyncMetadata - .where { $0.recordName.eq(recordName) } - .select(\._lastKnownServerRecordAllFields) - .fetchOne(db) - } ?? nil + let lastKnownServerRecord = + try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordName.eq(recordName) } + .select(\._lastKnownServerRecordAllFields) + .fetchOne(db) + } ?? nil guard let lastKnownServerRecord else { throw SharingError( @@ -132,8 +133,8 @@ recordPrimaryKey: record.primaryKey.rawIdentifier, reason: .recordMetadataNotFound, debugDescription: """ - No sync metadata found for record. Has the record been saved to the database? - """ + No sync metadata found for record. Has the record been saved to the database? + """ ) } return try await container.database(for: lastKnownServerRecord.recordID) @@ -230,6 +231,10 @@ return } + try await unshare(share: share) + } + + func unshare(share: CKShare) async throws { let result = try await syncEngines.private?.database.modifyRecords( saving: [], deleting: [share.recordID] @@ -252,7 +257,126 @@ /// /// See for more info. @available(iOS 17, macOS 14, tvOS 17, *) - public struct CloudSharingView: UIViewControllerRepresentable { + public struct CloudSharingView: View { + let sharedRecord: SharedRecord + let availablePermissions: UICloudSharingController.PermissionOptions + let didFinish: (Result) -> Void + let didStopSharing: () -> Void + let syncEngine: SyncEngine + @Dependency(\.context) var context + @Environment(\.dismiss) var dismiss + public init( + sharedRecord: SharedRecord, + availablePermissions: UICloudSharingController.PermissionOptions = [], + didFinish: @escaping (Result) -> Void = { _ in }, + didStopSharing: @escaping () -> Void = {}, + syncEngine: SyncEngine = { + @Dependency(\.defaultSyncEngine) var defaultSyncEngine + return defaultSyncEngine + }() + ) { + self.sharedRecord = sharedRecord + self.didFinish = didFinish + self.didStopSharing = didStopSharing + self.availablePermissions = availablePermissions + self.syncEngine = syncEngine + } + public var body: some View { + if context == .live { + CloudSharingViewRepresentable( + sharedRecord: sharedRecord, + availablePermissions: availablePermissions, + didFinish: didFinish, + didStopSharing: didStopSharing, + syncEngine: syncEngine + ) + } else { + NavigationStack { + Form { + VStack(alignment: .center, spacing: 10) { + Group { + if let data = sharedRecord.share[CKShare.SystemFieldKey.thumbnailImageData] + as? Data, + let uiImage = UIImage(data: data) + { + Image(uiImage: uiImage) + } else { + Text("☁️") + } + } + .font(.system(size: 96)) + Text( + sharedRecord.share[CKShare.SystemFieldKey.title] as? String + ?? "Share" + ) + .font(.title.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + + Section { + HStack { + Image(systemName: "person.crop.circle.fill") + .imageScale(.large) + .font(.title) + .foregroundStyle( + Gradient(colors: [ + Color(red: 0.7, green: 0.75, blue: 0.9), + Color(red: 0.4, green: 0.45, blue: 0.6), + ]) + ) + NavigationLink("(Owner)", value: Bool?.none) + } + } + + Section { + VStack(alignment: .leading) { + Text("\(Image(systemName: "eye.fill")) Share Preview") + .font(.headline) + Text( + """ + This is a mock screen used only in previews. You are not interacting with iCloud. + """ + ) + .font(.callout) + .foregroundStyle(.gray) + } + } + + Button("Stop Sharing", role: .destructive) { + Task { + try await syncEngine.unshare(share: sharedRecord.share) + try await syncEngine.fetchChanges() + dismiss() + } + } + .frame(maxWidth: .infinity) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "checkmark") + .font(.headline) + .foregroundStyle(.white) + .padding(6) + .background(Circle().fill(.blue)) + } + } + } + } + .task { + await withErrorReporting { + try await syncEngine.fetchChanges() + } + } + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, *) + private struct CloudSharingViewRepresentable: UIViewControllerRepresentable { let sharedRecord: SharedRecord let availablePermissions: UICloudSharingController.PermissionOptions let didFinish: (Result) -> Void diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 8ea33c42..c8efa856 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -25,12 +25,7 @@ throw InMemoryDatabase() } - let metadatabase: any DatabaseWriter = - if url.isInMemory { - try DatabaseQueue(path: url.absoluteString) - } else { - try DatabasePool(path: url.path(percentEncoded: false)) - } + let metadatabase = try DatabasePool(path: url.path(percentEncoded: false)) try migrate(metadatabase: metadatabase) return metadatabase } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index bb043eda..aaf8568b 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -47,8 +47,8 @@ ? privateCloudDatabase : sharedCloudDatabase - let rootRecord: CKRecord? = database.storage.withValue { - $0[share.recordID.zoneID]?.records.values.first { record in + let rootRecord: CKRecord? = database.state.withValue { + $0.storage[share.recordID.zoneID]?.records.values.first { record in record.share?.recordID == share.recordID } } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 0c2fa6a0..ae093dd5 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -4,12 +4,22 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockCloudDatabase: CloudDatabase { - package let storage = LockIsolated<[CKRecordZone.ID: Zone]>([:]) - let assets = LockIsolated<[AssetID: Data]>([:]) + package let state = LockIsolated(State()) package let databaseScope: CKDatabase.Scope let _container = IsolatedWeakVar() let dataManager = Dependency(\.dataManager) + package struct State { + private var lastRecordChangeTag = 0 + package var storage: [CKRecordZone.ID: Zone] = [:] + var assets: [AssetID: Data] = [:] + var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = [] + mutating func nextRecordChangeTag() -> Int { + lastRecordChangeTag += 1 + return lastRecordChangeTag + } + } + struct AssetID: Hashable { let recordID: CKRecord.ID let key: String @@ -36,16 +46,19 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } - guard let zone = storage[recordID.zoneID] - else { throw CKError(.zoneNotFound) } - guard let record = zone.records[recordID] - else { throw CKError(.unknownItem) } - guard let record = record.copy() as? CKRecord - else { fatalError("Could not copy CKRecord.") } - - try assets.withValue { assets in + let record = try state.withValue { state in + guard let zone = state.storage[recordID.zoneID] + else { throw CKError(.zoneNotFound) } + guard let record = zone.records[recordID] + else { throw CKError(.unknownItem) } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + return record + } + + try state.withValue { state in for key in record.allKeys() { - guard let assetData = assets[AssetID(recordID: record.recordID, key: key)] + guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] else { continue } let url = URL(filePath: UUID().uuidString.lowercased()) try dataManager.wrappedValue.save(assetData, to: url) @@ -92,8 +105,8 @@ throw CKError(.limitExceeded) } - return storage.withValue { storage in - let previousStorage = storage + return state.withValue { state in + let previousStorage = state.storage var saveResults: [CKRecord.ID: Result] = [:] var deleteResults: [CKRecord.ID: Result] = [:] @@ -105,7 +118,7 @@ $0.share?.recordID == share.recordID }) let shareWasPreviouslySaved = - storage[share.recordID.zoneID]?.records[share.recordID] != nil + state.storage[share.recordID.zoneID]?.records[share.recordID] != nil guard shareWasPreviouslySaved || isSavingRootRecord else { saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) @@ -122,20 +135,20 @@ } // NB: Emit 'zoneNotFound' error if saving record with a zone not found in database. - guard storage[recordToSave.recordID.zoneID] != nil + guard state.storage[recordToSave.recordID.zoneID] != nil else { saveResults[recordToSave.recordID] = .failure(CKError(.zoneNotFound)) continue } - let existingRecord = storage[recordToSave.recordID.zoneID]?.records[ + let existingRecord = state.storage[recordToSave.recordID.zoneID]?.records[ recordToSave.recordID ] func saveRecordToDatabase() { let hasReferenceViolation = recordToSave.parent.map { parent in - storage[parent.recordID.zoneID]?.records[parent.recordID] == nil + state.storage[parent.recordID.zoneID]?.records[parent.recordID] == nil && !recordsToSave.contains { $0.recordID == parent.recordID } } ?? false @@ -148,12 +161,12 @@ func root(of record: CKRecord) -> CKRecord { guard let parent = record.parent else { return record } - return (storage[parent.recordID.zoneID]?.records[parent.recordID]).map( + return (state.storage[parent.recordID.zoneID]?.records[parent.recordID]).map( root ) ?? record } func share(for rootRecord: CKRecord) -> CKShare? { - for (_, record) in storage[rootRecord.recordID.zoneID]?.records ?? [:] { + for (_, record) in state.storage[rootRecord.recordID.zoneID]?.records ?? [:] { guard record.recordID == rootRecord.share?.recordID else { continue } return record as? CKShare @@ -175,19 +188,18 @@ guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } - copy._recordChangeTag = UUID().uuidString - - assets.withValue { assets in - for key in copy.allKeys() { - guard let assetURL = (copy[key] as? CKAsset)?.fileURL - else { continue } - assets[AssetID(recordID: copy.recordID, key: key)] = try? dataManager.wrappedValue - .load(assetURL) - } + copy._recordChangeTag = state.nextRecordChangeTag() + + for key in copy.allKeys() { + guard let assetURL = (copy[key] as? CKAsset)?.fileURL + else { continue } + state.assets[AssetID(recordID: copy.recordID, key: key)] = + try? dataManager.wrappedValue + .load(assetURL) } // TODO: This should merge copy's values to more accurately reflect reality - storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = copy + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = copy saveResults[recordToSave.recordID] = .success(copy) // NB: "Touch" parent records when saving a child: @@ -195,11 +207,12 @@ // If the parent isn't also being saved in this batch. !recordsToSave.contains(where: { $0.recordID == parent.recordID }), // And if the parent is in the database. - let parentRecord = storage[parent.recordID.zoneID]?.records[parent.recordID]?.copy() + let parentRecord = state.storage[parent.recordID.zoneID]?.records[parent.recordID]? + .copy() as? CKRecord { - parentRecord._recordChangeTag = UUID().uuidString - storage[parent.recordID.zoneID]?.records[parent.recordID] = parentRecord + parentRecord._recordChangeTag = state.nextRecordChangeTag() + state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentRecord } } @@ -252,13 +265,13 @@ fatalError() } for recordIDToDelete in recordIDsToDelete { - guard storage[recordIDToDelete.zoneID] != nil + guard state.storage[recordIDToDelete.zoneID] != nil else { deleteResults[recordIDToDelete] = .failure(CKError(.zoneNotFound)) continue } let hasReferenceViolation = !Set( - storage[recordIDToDelete.zoneID]?.records.values + state.storage[recordIDToDelete.zoneID]?.records.values .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } ?? [] ) @@ -270,9 +283,12 @@ deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) continue } - let recordToDelete = storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] - storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] = nil + let recordToDelete = state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] + state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] = nil deleteResults[recordIDToDelete] = .success(()) + if let recordType = recordToDelete?.recordType { + state.deletedRecords.append((recordIDToDelete, recordType)) + } // NB: If deleting a share that the current user owns, delete the shared records and all // associated records. @@ -281,14 +297,17 @@ shareToDelete.recordID.zoneID.ownerName == CKCurrentUserDefaultName { func deleteRecords(referencing recordID: CKRecord.ID) { - for recordToDelete in (storage[recordIDToDelete.zoneID]?.records ?? [:]).values { + for recordToDelete in (state.storage[recordIDToDelete.zoneID]?.records ?? [:]).values + { guard recordToDelete.share?.recordID == recordID || recordToDelete.parent?.recordID == recordID else { continue } - storage[recordIDToDelete.zoneID]?.records[recordToDelete.recordID] = nil + state.storage[recordIDToDelete.zoneID]?.records[recordToDelete.recordID] = nil + deleteResults[recordToDelete.recordID] = .success(()) + state.deletedRecords.append((recordIDToDelete, recordToDelete.recordType)) deleteRecords(referencing: recordToDelete.recordID) } } @@ -329,7 +348,7 @@ deleteResults[deleteSuccessRecordID] = .failure(CKError(.batchRequestFailed)) } // All storage changes are reverted in zone. - storage[zoneID]?.records = previousStorage[zoneID]?.records ?? [:] + state.storage[zoneID]?.records = previousStorage[zoneID]?.records ?? [:] } return (saveResults: saveResults, deleteResults: deleteResults) } @@ -346,23 +365,23 @@ guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } - return storage.withValue { storage in + return state.withValue { state in var saveResults: [CKRecordZone.ID: Result] = [:] var deleteResults: [CKRecordZone.ID: Result] = [:] for recordZoneToSave in recordZonesToSave { - storage[recordZoneToSave.zoneID] = - storage[recordZoneToSave.zoneID] ?? Zone(zone: recordZoneToSave) + state.storage[recordZoneToSave.zoneID] = + state.storage[recordZoneToSave.zoneID] ?? Zone(zone: recordZoneToSave) saveResults[recordZoneToSave.zoneID] = .success(recordZoneToSave) } for recordZoneIDsToDelete in recordZoneIDsToDelete { - guard storage[recordZoneIDsToDelete] != nil + guard state.storage[recordZoneIDsToDelete] != nil else { deleteResults[recordZoneIDsToDelete] = .failure(CKError(.zoneNotFound)) continue } - storage[recordZoneIDsToDelete] = nil + state.storage[recordZoneIDsToDelete] = nil deleteResults[recordZoneIDsToDelete] = .success(()) } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 336cb6c1..b30f3390 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -7,7 +7,7 @@ package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase package let parentSyncEngine: SyncEngine - private let _state: LockIsolated + package let state: MockSyncEngineState package let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) package let _acceptedShareMetadata = LockIsolated>([]) @@ -18,41 +18,65 @@ ) { self.database = database self.parentSyncEngine = parentSyncEngine - self._state = LockIsolated(state) + self.state = state } package var scope: CKDatabase.Scope { database.databaseScope } - package var state: MockSyncEngineState { - _state.withValue(\.self) - } - package func acceptShare(metadata: ShareMetadata) { _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - let records: [CKRecord] + let modifications: [CKRecord] let zoneIDs: [CKRecordZone.ID] switch options.scope { case .all: - zoneIDs = Array(database.storage.keys) + zoneIDs = Array(database.state.storage.keys) case .allExcluding(let excludedZoneIDs): - zoneIDs = Array(Set(database.storage.keys).subtracting(excludedZoneIDs)) + zoneIDs = Array(Set(database.state.storage.keys).subtracting(excludedZoneIDs)) case .zoneIDs(let includedZoneIDs): zoneIDs = includedZoneIDs @unknown default: fatalError() } - records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in - accum += database.storage.withValue { - ($0[zoneID]?.records.values).map { Array($0) } ?? [] + + modifications = database.state.withValue { state in + zoneIDs.reduce(into: [CKRecord]()) { + accum, + zoneID in + accum += ((state.storage[zoneID]?.records.values).map { Array($0) } ?? []) + .filter { + precondition( + $0._recordChangeTag != nil, + "Records stored in database should have their 'recordChangeTag' assigned." + ) + return $0._recordChangeTag! > self.state.changeTag.value + } + } + } + + let deletions = database.state.withValue { + let records = $0.deletedRecords.filter { recordID, _ in + zoneIDs.contains(recordID.zoneID) } + $0.deletedRecords.removeAll { lhsRecordID, _ in + records.contains { rhsRecordID, _ in lhsRecordID == rhsRecordID } + } + return records + } + + guard !modifications.isEmpty || !deletions.isEmpty + else { return } + + state.changeTag.withValue { changeTag in + changeTag = modifications.compactMap(\._recordChangeTag).max() ?? changeTag } + await parentSyncEngine.handleEvent( - .fetchedRecordZoneChanges(modifications: records, deletions: []), + .fetchedRecordZoneChanges(modifications: modifications, deletions: deletions), syncEngine: self ) } @@ -110,6 +134,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngineState: CKSyncEngineStateProtocol { + package let changeTag = LockIsolated(0) package let _pendingRecordZoneChanges = LockIsolated< OrderedSet >([] diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index efc0fec1..8edf5bac 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -38,6 +38,9 @@ private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil) private let activityCounts = LockIsolated(ActivityCounts()) private let startTask = LockIsolated?>(nil) + #if canImport(DeveloperToolsSupport) + private let previewTimerTask = LockIsolated?>(nil) + #endif /// The error message used when a write occurs to a record for which the current user does not /// have permission. @@ -420,6 +423,12 @@ /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { guard isRunning else { return } + #if canImport(DeveloperToolsSupport) + previewTimerTask.withValue { + $0?.cancel() + $0 = nil + } + #endif observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { $0 = SyncEngines() @@ -494,6 +503,24 @@ } ) + #if canImport(DeveloperToolsSupport) + @Dependency(\.context) var context + @Dependency(\.continuousClock) var clock + if context == .preview { + previewTimerTask.withValue { + $0?.cancel() + $0 = Task { [weak self] in + await withErrorReporting { + while true { + guard let self else { break } + try await clock.sleep(for: .seconds(1)) + try await self.syncChanges() + } + } + } + } + } + #endif let startTask = Task { await withErrorReporting(.sqliteDataCloudKitFailure) { guard try await container.accountStatus() == .available @@ -512,7 +539,10 @@ try await cacheUserTables(recordTypes: currentRecordTypes) } } - self.startTask.withValue { $0 = startTask } + self.startTask.withValue { + $0?.cancel() + $0 = startTask + } return startTask } @@ -574,8 +604,8 @@ fetchOptions: CKSyncEngine.FetchChangesOptions = CKSyncEngine.FetchChangesOptions(), sendOptions: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions() ) async throws { - try await fetchChanges(fetchOptions) try await sendChanges(sendOptions) + try await fetchChanges(fetchOptions) } private func cacheUserTables(recordTypes: [RecordType]) async throws { @@ -1765,8 +1795,9 @@ switch error.code { case .referenceViolation: enqueuedUnsyncedRecordID = true - try UnsyncedRecordID.insert(or: .ignore) { + try UnsyncedRecordID.insert { UnsyncedRecordID(recordID: failedRecordID) + } onConflictDoUpdate: { _ in } .execute(db) syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) @@ -1931,8 +1962,9 @@ else { throw error } - try UnsyncedRecordID.insert(or: .ignore) { + try UnsyncedRecordID.insert { UnsyncedRecordID(recordID: serverRecord.recordID) + } onConflictDoUpdate: { _ in } .execute(db) } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift index 63beb0fe..31a0cf13 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -33,9 +33,7 @@ public func defaultDatabase( } } database = try DatabasePool(path: path ?? defaultPath, configuration: configuration) - case .preview: - database = try DatabaseQueue(configuration: configuration) - case .test: + case .preview, .test: database = try DatabasePool( path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", configuration: configuration diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 7b778081..5c0a9347 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -666,7 +666,7 @@ let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) record.setValue("Work", forKey: "title", at: now) // NB: Manually setting '_recordChangeTag' simulates another device saving a record. - record._recordChangeTag = UUID().uuidString + record._recordChangeTag = .random(in: 9999 ... .max) try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() assertQuery(Reminder.all, database: userDatabase.database) { diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 5bd8f224..cc2883d6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -668,7 +668,7 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?.records[ + of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ Reminder.recordID(for: 1) ], as: .customDump @@ -763,7 +763,7 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.storage[syncEngine.defaultZone.zoneID]?.records[ + of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ Reminder.recordID(for: 1) ], as: .customDump diff --git a/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift new file mode 100644 index 00000000..10dffa67 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift @@ -0,0 +1,109 @@ +#if canImport(CloudKit) + import DependenciesTestSupport + import InlineSnapshotTesting + import SnapshotTestingCustomDump + import SQLiteData + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite(.dependencies { $0.context = .preview }) + final class PreviewTests: BaseCloudKitTests, @unchecked Sendable { + @Test + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func autoSyncChangesInPreviews() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + await testClock.advance(by: .seconds(1)) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func delete() async throws { + @FetchAll(RemindersList.all, database: userDatabase.database) var remindersLists + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + + await testClock.advance(by: .seconds(1)) + try await $remindersLists.load() + #expect(remindersLists.count == 1) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.userWrite { db in + try RemindersList.delete().execute(db) + } + try await $remindersLists.load() + #expect(remindersLists.count == 0) + + await testClock.advance(by: .seconds(1)) + #expect(remindersLists.count == 0) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index aebc26ab..f28d37b3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -298,10 +298,10 @@ try await userDatabase.userWrite { db in try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 - """ + """ + ALTER TABLE "remindersLists" + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + """ ) .execute(db) } @@ -310,9 +310,9 @@ userDatabase: syncEngine.userDatabase, tables: syncEngine.tables .filter { $0.base != RemindersList.self } - + [ - SynchronizedTable(for: RemindersListWithPosition.self), - ], + + [ + SynchronizedTable(for: RemindersListWithPosition.self) + ], privateTables: syncEngine.privateTables ) defer { _ = newSyncEngine } diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 4aec1bc8..d54dc32b 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -1,242 +1,250 @@ -import CloudKit -import DependenciesTestSupport -import OrderedCollections -import SQLiteData -import SnapshotTesting -import Testing -import os +#if canImport(CloudKit) + import Clocks + import CloudKit + import DependenciesTestSupport + import OrderedCollections + import SQLiteData + import SnapshotTesting + import Testing + import os -@Suite( - .snapshots(record: .missing), - .dependencies { - $0.currentTime.now = 0 - $0.dataManager = InMemoryDataManager() - }, - .attachMetadatabase(false) -) -class BaseCloudKitTests: @unchecked Sendable { - let userDatabase: UserDatabase - private let _syncEngine: any Sendable - private let _container: any Sendable + @Suite( + .snapshots(record: .missing), + .dependencies { + $0.currentTime.now = 0 + $0.continuousClock = TestClock() + $0.dataManager = InMemoryDataManager() + }, + .attachMetadatabase(false) + ) + class BaseCloudKitTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable - @Dependency(\.currentTime.now) var now - @Dependency(\.dataManager) var dataManager - var inMemoryDataManager: InMemoryDataManager { - dataManager as! InMemoryDataManager - } + @Dependency(\.continuousClock) var clock + @Dependency(\.currentTime.now) var now + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } + var testClock: TestClock { + clock as! TestClock + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var container: MockCloudContainer { - _container as! MockCloudContainer - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var syncEngine: SyncEngine { - _syncEngine as! SyncEngine - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" - self.userDatabase = UserDatabase( - database: try SQLiteDataTests.database( + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase + ) + ) + try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: _AccountStatusScope.accountStatus, containerIdentifier: testContainerIdentifier, - attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase ) - ) - try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) - let privateDatabase = MockCloudDatabase(databaseScope: .private) - let sharedDatabase = MockCloudDatabase(databaseScope: .shared) - let container = MockCloudContainer( - accountStatus: _AccountStatusScope.accountStatus, - containerIdentifier: testContainerIdentifier, - privateCloudDatabase: privateDatabase, - sharedCloudDatabase: sharedDatabase - ) - _container = container - privateDatabase.set(container: container) - sharedDatabase.set(container: container) - _syncEngine = try await SyncEngine( - container: container, - userDatabase: self.userDatabase, - delegate: _SyncEngineDelegateTrait.syncEngineDelegate, - tables: Reminder.self, - RemindersList.self, - RemindersListAsset.self, - Tag.self, - ReminderTag.self, - Parent.self, - ChildWithOnDeleteSetNull.self, - ChildWithOnDeleteSetDefault.self, - ModelA.self, - ModelB.self, - ModelC.self, - privateTables: RemindersListPrivate.self, - startImmediately: _StartImmediatelyTrait.startImmediately - ) - if _StartImmediatelyTrait.startImmediately, - _AccountStatusScope.accountStatus == .available - { + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + _syncEngine = try await SyncEngine( + container: container, + userDatabase: self.userDatabase, + delegate: _SyncEngineDelegateTrait.syncEngineDelegate, + tables: Reminder.self, + RemindersList.self, + RemindersListAsset.self, + Tag.self, + ReminderTag.self, + Parent.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ModelA.self, + ModelB.self, + ModelC.self, + privateTables: RemindersListPrivate.self, + startImmediately: _StartImmediatelyTrait.startImmediately + ) + if _StartImmediatelyTrait.startImmediately, + _AccountStatusScope.accountStatus == .available + { + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signOut() async { + container._accountStatus.withValue { $0 = .noAccount } await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), syncEngine: syncEngine.private ) await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), syncEngine: syncEngine.shared ) - try await syncEngine.processPendingDatabaseChanges(scope: .private) } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func signOut() async { - container._accountStatus.withValue { $0 = .noAccount } - await syncEngine.handleEvent( - .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), - syncEngine: syncEngine.private - ) - await syncEngine.handleEvent( - .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), - syncEngine: syncEngine.shared - ) - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func softSignOut() async { - container._accountStatus.withValue { $0 = .temporarilyUnavailable } - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func softSignOut() async { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func signIn() async { - container._accountStatus.withValue { $0 = .available } - // NB: Emulates what CKSyncEngine does when signing in - syncEngine.private.state.removePendingChanges() - syncEngine.shared.state.removePendingChanges() - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), - syncEngine: syncEngine.private - ) - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), - syncEngine: syncEngine.shared - ) - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signIn() async { + container._accountStatus.withValue { $0 = .available } + // NB: Emulates what CKSyncEngine does when signing in + syncEngine.private.state.removePendingChanges() + syncEngine.shared.state.removePendingChanges() + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.shared + ) + } - deinit { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - guard syncEngine.isRunning - else { return } + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + guard syncEngine.isRunning + else { return } - syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.state.assertPendingDatabaseChanges([]) - syncEngine.shared.state.assertPendingRecordZoneChanges([]) - syncEngine.shared.assertAcceptedShareMetadata([]) - syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.state.assertPendingDatabaseChanges([]) - syncEngine.private.state.assertPendingRecordZoneChanges([]) - syncEngine.private.assertAcceptedShareMetadata([]) + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) - try! syncEngine.metadatabase.read { db in - try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } + } else { + Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") } - } else { - Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine { - var `private`: MockSyncEngine { - syncEngines.private as! MockSyncEngine - } - var shared: MockSyncEngine { - syncEngines.shared as! MockSyncEngine - } - static nonisolated let defaultTestZone = CKRecordZone( - zoneName: "zone" - ) - convenience init< - each T1: PrimaryKeyedTable & _SendableMetatype, - each T2: PrimaryKeyedTable & _SendableMetatype - >( - container: any CloudContainer, - userDatabase: UserDatabase, - delegate: (any SyncEngineDelegate)? = nil, - tables: repeat (each T1).Type, - privateTables: repeat (each T2).Type, - startImmediately: Bool = true - ) async throws - where - repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, - repeat (each T1).TableColumns.PrimaryColumn: WritableTableColumnExpression, - repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible, - repeat (each T2).TableColumns.PrimaryColumn: WritableTableColumnExpression - { - var allTables: [any SynchronizableTable] = [] - var allPrivateTables: [any SynchronizableTable] = [] - for table in repeat each tables { - allTables.append(SynchronizedTable(for: table)) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine } - for privateTable in repeat each privateTables { - allPrivateTables.append(SynchronizedTable(for: privateTable)) + var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine } - try await self.init( - container: container, - userDatabase: userDatabase, - delegate: delegate, - tables: allTables, - privateTables: allPrivateTables, - startImmediately: startImmediately + static nonisolated let defaultTestZone = CKRecordZone( + zoneName: "zone" ) - } - convenience init( - container: any CloudContainer, - userDatabase: UserDatabase, - delegate: (any SyncEngineDelegate)? = nil, - tables: [any SynchronizableTable], - privateTables: [any SynchronizableTable] = [], - startImmediately: Bool = true - ) async throws { - try self.init( - container: container, - defaultZone: Self.defaultTestZone, - defaultSyncEngines: { _, syncEngine in - ( - MockSyncEngine( - database: container.privateCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() - ), - MockSyncEngine( - database: container.sharedCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() + convenience init< + each T1: PrimaryKeyedTable & _SendableMetatype, + each T2: PrimaryKeyedTable & _SendableMetatype + >( + container: any CloudContainer, + userDatabase: UserDatabase, + delegate: (any SyncEngineDelegate)? = nil, + tables: repeat (each T1).Type, + privateTables: repeat (each T2).Type, + startImmediately: Bool = true + ) async throws + where + repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T1).TableColumns.PrimaryColumn: WritableTableColumnExpression, + repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T2).TableColumns.PrimaryColumn: WritableTableColumnExpression + { + var allTables: [any SynchronizableTable] = [] + var allPrivateTables: [any SynchronizableTable] = [] + for table in repeat each tables { + allTables.append(SynchronizedTable(for: table)) + } + for privateTable in repeat each privateTables { + allPrivateTables.append(SynchronizedTable(for: privateTable)) + } + try await self.init( + container: container, + userDatabase: userDatabase, + delegate: delegate, + tables: allTables, + privateTables: allPrivateTables, + startImmediately: startImmediately + ) + } + convenience init( + container: any CloudContainer, + userDatabase: UserDatabase, + delegate: (any SyncEngineDelegate)? = nil, + tables: [any SynchronizableTable], + privateTables: [any SynchronizableTable] = [], + startImmediately: Bool = true + ) async throws { + try self.init( + container: container, + defaultZone: Self.defaultTestZone, + defaultSyncEngines: { _, syncEngine in + ( + MockSyncEngine( + database: container.privateCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ), + MockSyncEngine( + database: container.sharedCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ) ) - ) - }, - userDatabase: userDatabase, - logger: Logger(.disabled), - delegate: delegate, - tables: tables, - privateTables: privateTables - ) - try setUpSyncEngine() - if startImmediately { - try await start() + }, + userDatabase: userDatabase, + logger: Logger(.disabled), + delegate: delegate, + tables: tables, + privateTables: privateTables + ) + try setUpSyncEngine() + if startImmediately { + try await start() + } } } -} -private let previousUserRecordID = CKRecord.ID( - recordName: "previousUser" -) -private let currentUserRecordID = CKRecord.ID( - recordName: "currentUser" -) + private let previousUserRecordID = CKRecord.ID( + recordName: "previousUser" + ) + private let currentUserRecordID = CKRecord.ID( + recordName: "currentUser" + ) -// NB: This conformance is only used for ease of testing. In general it is not appropriate to -// conform integer types to this protocol. -extension Int: IdentifierStringConvertible {} + // NB: This conformance is only used for ease of testing. In general it is not appropriate to + // conform integer types to this protocol. + extension Int: IdentifierStringConvertible {} +#endif diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 5874e5d6..36ae5fc8 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -237,8 +237,9 @@ self, children: [ "databaseScope": databaseScope, - "storage": storage + "storage": state .value + .storage .flatMap { _, value in value.records.values } .sorted { ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 207fd4c8..4716be7b 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -75,8 +75,10 @@ extension SyncEngine { > { let syncEngine = syncEngine(for: scope) let recordsToDeleteByID = Dictionary( - grouping: syncEngine.database.storage.withValue { storage in - recordIDsToDelete.compactMap { recordID in storage[recordID.zoneID]?.records[recordID] } + grouping: syncEngine.database.state.withValue { state in + recordIDsToDelete.compactMap { + recordID in state.storage[recordID.zoneID]?.records[recordID] + } }, by: \.recordID ) @@ -93,12 +95,10 @@ extension SyncEngine { .fetchedRecordZoneChanges( modifications: saveResults.values.compactMap { try? $0.get() }, deletions: deleteResults.compactMap { recordID, result in - syncEngine.database.storage.withValue { storage in - (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in - (try? result.get()) != nil - ? (recordID, recordType) - : nil - } + (recordsToDeleteByID[recordID]?.recordType).flatMap { recordType in + (try? result.get()) != nil + ? (recordID, recordType) + : nil } } ),