diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 1a1a5c6f..ef764b0b 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 1f540899..94b21e03 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", + "originHash" : "82b8ac0230a95cb2adcecfb43731949871837d431deb3b5df27bf186c483fea2", "pins" : [ { "identity" : "combine-schedulers", @@ -73,6 +73,24 @@ "version" : "1.10.0" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-identified-collections", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 971a8979..2be7ad13 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..053d6121 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -363,10 +363,6 @@ func appDatabase() throws -> any DatabaseWriter { } ) .execute(db) - - if context != .live { - try db.seedSampleData() - } } return database @@ -410,134 +406,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: """ + 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) + 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..8d81cc40 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -250,7 +250,8 @@ 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 { diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index c40b777a..61510300 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 { @@ -163,7 +164,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 61c1dd30..347a57f1 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: .failed) diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 8faeceb1..02009fe7 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -67,62 +67,60 @@ extension BaseTestSuite { let model = SearchRemindersModel() model.searchText = "Take" try await model.showCompletedButtonTapped() + try await model.$searchResults.load() + try await model.searchTask?.value - withKnownIssue( - "'@Fetch' introduces an escaping closure and loses the task-local dependency" - ) { - assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, - notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" - ), - tags: "", - title: "**Take** out trash" + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" ), - [1]: SearchRemindersModel.Row( - isPastDue: false, + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" + ), + [1]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000006), + dueDate: Date(2008-08-07T23:31:30.000Z), + isCompleted: true, + isFlagged: false, notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-000000000006), - dueDate: Date(2008-08-07T23:31:30.000Z), - isCompleted: true, - isFlagged: false, - notes: "", - position: 4, - priority: nil, - remindersListID: UUID(00000000-0000-0000-0000-000000000000), - title: "Take a walk" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000000), - color: 1218047999, - position: 1, - title: "Personal" - ), - tags: "#car #kids #social", - title: "**Take** a walk" - ) - ] - """ - } + position: 4, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Take a walk" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: 1218047999, + position: 1, + title: "Personal" + ), + tags: "#car #kids #social", + title: "**Take** a walk" + ) + ] + """ } } diff --git a/Examples/SyncUps/App.swift b/Examples/SyncUps/App.swift index cf6da384..77b201e6 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..9b9050b2 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..a19791d7 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/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 09f4181b..ed08b520 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -227,6 +227,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] @@ -249,7 +253,76 @@ /// /// 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 { + Section { + if let title = sharedRecord.share[CKShare.SystemFieldKey.title] as? String { + Text(title) + } + if let imageData = sharedRecord.share[CKShare.SystemFieldKey.thumbnailImageData] + as? Data, + let image = UIImage(data: imageData) + { + Image(uiImage: image) + } + } + Section { + Button("Stop sharing", role: .destructive) { + Task { + try await syncEngine.unshare(share: sharedRecord.share) + try await syncEngine.fetchChanges() + dismiss() + } + } + } + } + .navigationTitle("Share") + } + .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/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 21c3c8a0..4f69c0da 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -9,6 +9,7 @@ package let databaseScope: CKDatabase.Scope let _container = IsolatedWeakVar() let dataManager = Dependency(\.dataManager) + let deletedRecords = LockIsolated<[(CKRecord.ID, CKRecord.RecordType)]>([]) struct AssetID: Hashable { let recordID: CKRecord.ID @@ -261,6 +262,9 @@ let recordToDelete = storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] = nil deleteResults[recordIDToDelete] = .success(()) + if let recordType = recordToDelete?.recordType { + deletedRecords.withValue { $0.append((recordIDToDelete, recordType)) } + } // NB: If deleting a share that the current user owns, delete the shared records and all // associated records. @@ -277,6 +281,8 @@ continue } storage[recordIDToDelete.zoneID]?.records[recordToDelete.recordID] = nil + deleteResults[recordToDelete.recordID] = .success(()) + deletedRecords.withValue { $0.append((recordIDToDelete, recordToDelete.recordType)) } deleteRecords(referencing: recordToDelete.recordID) } } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 336cb6c1..24d6587f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -34,7 +34,7 @@ } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { - let records: [CKRecord] + let modifications: [CKRecord] let zoneIDs: [CKRecordZone.ID] switch options.scope { case .all: @@ -46,13 +46,24 @@ @unknown default: fatalError() } - records = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + modifications = zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in accum += database.storage.withValue { ($0[zoneID]?.records.values).map { Array($0) } ?? [] } } await parentSyncEngine.handleEvent( - .fetchedRecordZoneChanges(modifications: records, deletions: []), + .fetchedRecordZoneChanges( + modifications: modifications, + deletions: database.deletedRecords.withValue { + let records = $0.filter { recordID, _ in + zoneIDs.contains(recordID.zoneID) + } + $0.removeAll { lhs, _ in + records.contains { rhs, _ in lhs == rhs } + } + return records + } + ), syncEngine: self ) } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 24891aa4..9e5bb0d7 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,23 @@ } ) + #if canImport(DeveloperToolsSupport) + @Dependency(\.context) var context + if context == .preview { + previewTimerTask.withValue { + $0?.cancel() + $0 = Task { [weak self] in + await withErrorReporting { + while true { + guard let self else { break } + try await Task.sleep(for: .seconds(1)) + try await self.syncChanges() + } + } + } + } + } + #endif let startTask = Task { await withErrorReporting(.sqliteDataCloudKitFailure) { guard try await container.accountStatus() == .available @@ -512,7 +538,10 @@ try await cacheUserTables(recordTypes: currentRecordTypes) } } - self.startTask.withValue { $0 = startTask } + self.startTask.withValue { + $0?.cancel() + $0 = startTask + } return startTask } @@ -1502,7 +1531,8 @@ } var unsyncedRecords: [CKRecord] = [] for start in stride(from: 0, to: orderedUnsyncedRecordIDs.count, by: batchSize) { - let recordIDsBatch = orderedUnsyncedRecordIDs + let recordIDsBatch = + orderedUnsyncedRecordIDs .dropFirst(start) .prefix(batchSize) let results = try await syncEngine.database.records(for: Array(recordIDsBatch)) @@ -1586,7 +1616,7 @@ return false case (_, nil): return true - case let (.some(lhs), .some(rhs)): + case (.some(let lhs), .some(let rhs)): let lhsIndex = tablesByOrder[lhs] ?? (rootFirst ? .max : .min) let rhsIndex = tablesByOrder[rhs] ?? (rootFirst ? .max : .min) guard lhsIndex != rhsIndex @@ -1759,8 +1789,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)]) @@ -1925,8 +1956,9 @@ else { throw error } - try UnsyncedRecordID.insert(or: .ignore) { + try UnsyncedRecordID.insert { UnsyncedRecordID(recordID: serverRecord.recordID) + } onConflictDoUpdate: { _ in } .execute(db) } @@ -2328,7 +2360,8 @@ tablesByName: [String: any SynchronizableTable] ) throws -> [String: Int] { let tableDependencies = try userDatabase.read { db in - var dependencies: OrderedDictionary = [:] + var dependencies: OrderedDictionary = + [:] for table in tables { func open(_: some SynchronizableTable) throws -> [String] { try PragmaForeignKeyList 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/PreviewTests.swift b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift new file mode 100644 index 00000000..a6350969 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift @@ -0,0 +1,47 @@ +#if canImport(CloudKit) + import InlineSnapshotTesting + import SQLiteData + import SnapshotTestingCustomDump + import DependenciesTestSupport + import Testing + + extension BaseCloudKitTests { + @MainActor + @Suite(.dependencies { $0.context = .preview }) + final class PreviewTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test + func autoSyncChangesInPreviews() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await Task.sleep(for: .seconds(1.2)) + 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: [] + ) + ) + """ + } + } + } + } +#endif