From 3845a39d2d87a7bd7a92de32c84f2e164d4c1088 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 14 Sep 2025 14:33:25 -0500 Subject: [PATCH 1/9] Modernize reminders with triggers. --- .../xcshareddata/swiftpm/Package.resolved | 9 ++ Examples/Reminders/ReminderForm.swift | 8 +- Examples/Reminders/ReminderRow.swift | 38 +---- Examples/Reminders/RemindersDetail.swift | 11 +- Examples/Reminders/Schema.swift | 76 +++++++-- .../CloudKit/CloudKit+StructuredQueries.swift | 87 ++++++---- Sources/SQLiteData/CloudKit/SyncEngine.swift | 149 ++++++++++-------- 7 files changed, 228 insertions(+), 150 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c326768b..3ab9c5d3 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -154,6 +154,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 8a88abde..b7b70ed3 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -91,11 +91,11 @@ struct ReminderFormView: View { } } Picker(selection: $reminder.priority) { - Text("None").tag(Priority?.none) + Text("None").tag(Reminder.Priority?.none) Divider() - Text("High").tag(Priority.high) - Text("Medium").tag(Priority.medium) - Text("Low").tag(Priority.low) + Text("High").tag(Reminder.Priority.high) + Text("Medium").tag(Reminder.Priority.medium) + Text("Low").tag(Reminder.Priority.low) } label: { HStack { Image(systemName: "exclamationmark.circle.fill") diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index c67f50bc..59c58870 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -12,7 +12,6 @@ struct ReminderRow: View { let title: String? @State var editReminder: Reminder.Draft? - @State var isCompleted: Bool @Dependency(\.defaultDatabase) private var database @@ -34,14 +33,14 @@ struct ReminderRow: View { self.showCompleted = showCompleted self.tags = tags self.title = title - self.isCompleted = reminder.isCompleted } var body: some View { + let _ = print("!!!!!", "reminder row", reminder) HStack { HStack(alignment: .firstTextBaseline) { Button(action: completeButtonTapped) { - Image(systemName: isCompleted ? "circle.inset.filled" : "circle") + Image(systemName: reminder.isCompleted ? "circle.inset.filled" : "circle") .foregroundStyle(.gray) .font(.title2) .padding([.trailing], 5) @@ -59,7 +58,7 @@ struct ReminderRow: View { } } Spacer() - if !isCompleted { + if !reminder.isCompleted { HStack { if reminder.isFlagged { Image(systemName: "flag.fill") @@ -104,36 +103,15 @@ struct ReminderRow: View { .navigationTitle("Details") } } - .task(id: isCompleted) { - guard !showCompleted else { return } - guard - isCompleted, - isCompleted != reminder.isCompleted - else { return } - do { - try await Task.sleep(for: .seconds(2)) - toggleCompletion() - } catch {} - } } private func completeButtonTapped() { - if showCompleted { - toggleCompletion() - } else { - isCompleted.toggle() - } - } - - private func toggleCompletion() { withErrorReporting { try database.write { db in - isCompleted = - try Reminder + try Reminder .find(reminder.id) - .update { $0.isCompleted.toggle() } - .returning(\.isCompleted) - .fetchOne(db) ?? isCompleted + .update { $0.toggleStatus() } + .execute(db) } } } @@ -161,10 +139,10 @@ struct ReminderRow: View { HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) - .foregroundStyle(isCompleted ? .gray : remindersList.color) + .foregroundStyle(reminder.isCompleted ? .gray : remindersList.color) } highlight(title ?? reminder.title) - .foregroundStyle(isCompleted ? .gray : .primary) + .foregroundStyle(reminder.isCompleted ? .gray : .primary) } .font(.title3) } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index afc42e10..3e947119 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -92,10 +92,16 @@ class RemindersDetailModel: HashableObject { Reminder .where { if !showCompleted { - !$0.isCompleted + $0.status.neq(Reminder.Status.completed) + } + } + .order { + if showCompleted { + $0.isCompleted + } else { + $0.status.eq(Reminder.Status.completed) } } - .order(by: \.isCompleted) .order { switch ordering { case .dueDate: $0.dueDate.asc(nulls: .last) @@ -174,6 +180,7 @@ struct RemindersDetailView: View { @State var navigationTitleHeight: CGFloat = 36 var body: some View { + let _ = Self._printChanges() List { header ForEach(model.reminderRows) { row in diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 039fa024..373f578f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -4,6 +4,7 @@ import IssueReporting import OSLog import SQLiteData import SwiftUI +import Synchronization @Table struct RemindersList: Hashable, Identifiable { @@ -31,13 +32,36 @@ struct RemindersListAsset: Hashable, Identifiable { struct Reminder: Hashable, Identifiable { let id: UUID var dueDate: Date? - var isCompleted = false var isFlagged = false var notes = "" var position = 0 var priority: Priority? var remindersListID: RemindersList.ID + var status: Status = .incomplete var title = "" + var isCompleted: Bool { + status != .incomplete + } + enum Priority: Int, QueryBindable { + case low = 1 + case medium + case high + } + enum Status: Int, QueryBindable { + case completed = 1 + case completing = 2 + case incomplete = 0 + } +} +extension Updates { + mutating func toggleStatus() { +// self.status = Case(self.status) +// .when(Reminder.Status.incomplete, then: Reminder.Status.completed) +// .else(Reminder.Status.incomplete) + self.status = Case(self.status) + .when(Reminder.Status.incomplete, then: Reminder.Status.completing) + .else(Reminder.Status.incomplete) + } } extension Reminder.Draft: Identifiable {} @@ -49,12 +73,6 @@ struct Tag: Hashable, Identifiable { var id: String { title } } -enum Priority: Int, QueryBindable { - case low = 1 - case medium - case high -} - extension Reminder { static let incomplete = Self.where { !$0.isCompleted } static let withTags = group(by: \.id) @@ -63,6 +81,9 @@ extension Reminder { } extension Reminder.TableColumns { + var isCompleted: some QueryExpression { + status.neq(Reminder.Status.incomplete) + } var isPastDue: some QueryExpression { @Dependency(\.date.now) var now return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)") @@ -117,6 +138,7 @@ func appDatabase() throws -> any DatabaseWriter { configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in try db.attachMetadatabase() + db.add(function: $handleReminderStatusUpdate) #if DEBUG db.trace(options: .profile) { if context == .live { @@ -172,6 +194,7 @@ func appDatabase() throws -> any DatabaseWriter { "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, "remindersListID" TEXT NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, + "status" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -313,6 +336,17 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) +// try Reminder.createTemporaryTrigger( +// after: .update { +// $0.status +// } forEachRow: { old, new in +// Values($handleReminderStatusUpdate()) +// } when: { old, new in +// new.status.eq(Reminder.Status.completing) +// } +// ) +// .execute(db) + if context != .live { try db.seedSampleData() } @@ -321,6 +355,24 @@ func appDatabase() throws -> any DatabaseWriter { return database } +let reminderStatusMutex = Mutex?>(nil) +@DatabaseFunction +func handleReminderStatusUpdate() { + reminderStatusMutex.withLock { + $0?.cancel() + $0 = Task { + @Dependency(\.defaultDatabase) var database + try await Task.sleep(for: .seconds(5)) + try await database.write { db in + try Reminder + .where { $0.status.eq(Reminder.Status.completing) } + .update { $0.status = .completed } + .execute(db) + } + } + } +} + private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG @@ -370,8 +422,8 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[3], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, remindersListID: remindersListIDs[0], + status: .completed, title: "Take a walk" ) Reminder( @@ -391,17 +443,17 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[6], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, priority: .low, remindersListID: remindersListIDs[1], + status: .completed, title: "Get laundry" ) Reminder( id: reminderIDs[7], dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, priority: .high, remindersListID: remindersListIDs[1], + status: .incomplete, title: "Take out trash" ) Reminder( @@ -418,16 +470,16 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Reminder( id: reminderIDs[9], dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, priority: .medium, remindersListID: remindersListIDs[2], + status: .completed, title: "Send weekly emails" ) Reminder( id: reminderIDs[10], dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - isCompleted: false, remindersListID: remindersListIDs[2], + status: .incomplete, title: "Prepare for WWDC" ) let tagIDs = ["car", "kids", "someday", "optional", "social", "night", "adulting"] diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index ceaba56f..d3a5c754 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -154,6 +154,8 @@ } } +import os + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { @discardableResult @@ -163,9 +165,26 @@ at userModificationDate: Date ) -> Bool { guard - encryptedValues[at: key] < userModificationDate, + encryptedValues[at: key] < userModificationDate + || (abs(encryptedValues[at: key].timeIntervalSince(userModificationDate)) < 0.002) + , encryptedValues[key] != newValue - else { return false } + else { + let start = ContinuousClock().now + let duration = ContinuousClock().now.duration(to: start) + if Self.fooo, encryptedValues[key] != newValue { + Logger().info(""" + ⚠️⚠️⚠️ A write to '\(key)' is being ignored + key \(key) + encryptedValues[at: key]: \(self.encryptedValues[at: key].timeIntervalSince1970) + userModificationDate: \(userModificationDate.timeIntervalSince1970) + encryptedValues[key]: \(String(describing: self.encryptedValues[key])) + newValue: \(String(describing: newValue)) + """) + print("!!!") + } + return false + } encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate self.userModificationDate = userModificationDate @@ -215,39 +234,43 @@ return false } + @TaskLocal static var fooo = false + func update(with row: T, userModificationDate: Date) { - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let column = column as! any WritableTableColumnExpression - let value = Value(queryOutput: row[keyPath: column.keyPath]) - switch value.queryBinding { - case .blob(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .bool(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .double(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .date(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .int(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .null: - removeValue(forKey: column.name, at: userModificationDate) - case .text(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uint(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uuid(let value): - setValue( - value.uuidString.lowercased(), - forKey: column.name, - at: userModificationDate - ) - case .invalid(let error): - reportIssue(error) + Self.$fooo.withValue(true) { + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let column = column as! any WritableTableColumnExpression + let value = Value(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { + case .blob(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .bool(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .double(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .date(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .int(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .null: + removeValue(forKey: column.name, at: userModificationDate) + case .text(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .uint(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .uuid(let value): + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationDate + ) + case .invalid(let error): + reportIssue(error) + } } + open(column) } - open(column) } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 71245e50..63c5616c 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -601,6 +601,7 @@ as: ((String, CKRecord?.SystemFieldsRepresentation) -> Void).self ) func didUpdate(recordName: String, record: CKRecord?) { + print("didUpdate", recordName) let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( CKRecord.ID( @@ -853,6 +854,7 @@ } #endif + print("⚠️⚠️⚠️", "changes.count", changes.count) let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? @@ -1259,20 +1261,24 @@ case share(CKShare) case reference(CKShare.Reference) } - var shares: [ShareOrReference] = [] - for record in modifications { - if let share = record as? CKShare { - shares.append(.share(share)) - } else { - await upsertFromServerRecord(record) - if let shareReference = record.share { - shares.append(.reference(shareReference)) + let shares = LockIsolated<[ShareOrReference]>([]) + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + for record in modifications { + if let share = record as? CKShare { + shares.withValue { $0.append(.share(share)) } + } else { + upsertFromServerRecord(record, db: db) + if let shareReference = record.share { + shares.withValue { $0.append(.reference(shareReference)) } + } + } } } } await withTaskGroup(of: Void.self) { group in - for share in shares { + for share in shares.withValue(\.self) { group.addTask { switch share { case .share(let share): @@ -1495,93 +1501,96 @@ force: Bool = false ) async { await withErrorReporting(.sqliteDataCloudKitFailure) { + try await userDatabase.write { db in + upsertFromServerRecord(serverRecord, force: force, db: db) + } + } + } + + private func upsertFromServerRecord( + _ serverRecord: CKRecord, + force: Bool = false, + db: Database + ) { + withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { guard let recordPrimaryKey = serverRecord.recordID.recordPrimaryKey else { return } - try await userDatabase.write { db in - try SyncMetadata.insert { - SyncMetadata( - recordPrimaryKey: recordPrimaryKey, - recordType: serverRecord.recordType, - parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, - parentRecordType: serverRecord.parent?.recordID.tableName, - lastKnownServerRecord: serverRecord, - _lastKnownServerRecordAllFields: serverRecord, - share: nil, - userModificationDate: serverRecord.userModificationDate - ) - } onConflict: { - ($0.recordPrimaryKey, $0.recordType) - } doUpdate: { - $0.setLastKnownServerRecord(serverRecord) - } - .execute(db) + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: recordPrimaryKey, + recordType: serverRecord.recordType, + parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, + parentRecordType: serverRecord.parent?.recordID.tableName, + lastKnownServerRecord: serverRecord, + _lastKnownServerRecordAllFields: serverRecord, + share: nil, + userModificationDate: serverRecord.userModificationDate + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + $0.setLastKnownServerRecord(serverRecord) } + .execute(db) return } - let metadata = try await metadatabase.read { db in - try SyncMetadata - .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .fetchOne(db) - } + let metadata = try SyncMetadata + .where { $0.recordName.eq(serverRecord.recordID.recordName) } + .fetchOne(db) serverRecord.userModificationDate = - metadata?.userModificationDate ?? serverRecord.userModificationDate + metadata?.userModificationDate ?? serverRecord.userModificationDate - func open(_: T.Type) async throws { + func open(_: T.Type) throws { let columnNames: [String] if !force, let metadata, let allFields = metadata._lastKnownServerRecordAllFields { - columnNames = try await userDatabase.read { db in - var columnNames = T.TableColumns.writableColumns.map(\.name) - let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) - guard let row - else { - reportIssue( - """ - Local database record could not be found for '\(serverRecord.recordID.recordName)'. - """ - ) - return columnNames - } + var _columnNames = T.TableColumns.writableColumns.map(\.name) + let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) + if let row { serverRecord.update( with: allFields, row: T(queryOutput: row), - columnNames: &columnNames, + columnNames: &_columnNames, parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 - ? foreignKeysByTableName[T.tableName]?.first - : nil + ? foreignKeysByTableName[T.tableName]?.first + : nil + ) + } else { + reportIssue( + """ + Local database record could not be found for '\(serverRecord.recordID.recordName)'. + """ ) - return columnNames } + columnNames = _columnNames } else { columnNames = T.TableColumns.writableColumns.map(\.name) } - try await userDatabase.write { db in - do { - try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) - try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) - try SyncMetadata - .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .update { $0.setLastKnownServerRecord(serverRecord) } - .execute(db) - } catch { - guard - let error = error as? DatabaseError, - error.resultCode == .SQLITE_CONSTRAINT, - error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY - else { - throw error - } - try UnsyncedRecordID.insert(or: .ignore) { - UnsyncedRecordID(recordID: serverRecord.recordID) - } + do { + try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) + try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) + try SyncMetadata + .where { $0.recordName.eq(serverRecord.recordID.recordName) } + .update { $0.setLastKnownServerRecord(serverRecord) } .execute(db) + } catch { + guard + let error = error as? DatabaseError, + error.resultCode == .SQLITE_CONSTRAINT, + error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY + else { + throw error + } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: serverRecord.recordID) } + .execute(db) } } - try await open(table) + try open(table) } } From eec240b74dabab89dd9a2dde864813e948c6a61f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 14 Sep 2025 20:08:36 -0500 Subject: [PATCH 2/9] wip; --- .../CloudKit/CloudKit+StructuredQueries.swift | 87 +++++++------------ Sources/SQLiteData/CloudKit/SyncEngine.swift | 1 - 2 files changed, 32 insertions(+), 56 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index d3a5c754..ceaba56f 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -154,8 +154,6 @@ } } -import os - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { @discardableResult @@ -165,26 +163,9 @@ import os at userModificationDate: Date ) -> Bool { guard - encryptedValues[at: key] < userModificationDate - || (abs(encryptedValues[at: key].timeIntervalSince(userModificationDate)) < 0.002) - , + encryptedValues[at: key] < userModificationDate, encryptedValues[key] != newValue - else { - let start = ContinuousClock().now - let duration = ContinuousClock().now.duration(to: start) - if Self.fooo, encryptedValues[key] != newValue { - Logger().info(""" - ⚠️⚠️⚠️ A write to '\(key)' is being ignored - key \(key) - encryptedValues[at: key]: \(self.encryptedValues[at: key].timeIntervalSince1970) - userModificationDate: \(userModificationDate.timeIntervalSince1970) - encryptedValues[key]: \(String(describing: self.encryptedValues[key])) - newValue: \(String(describing: newValue)) - """) - print("!!!") - } - return false - } + else { return false } encryptedValues[key] = newValue encryptedValues[at: key] = userModificationDate self.userModificationDate = userModificationDate @@ -234,43 +215,39 @@ import os return false } - @TaskLocal static var fooo = false - func update(with row: T, userModificationDate: Date) { - Self.$fooo.withValue(true) { - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let column = column as! any WritableTableColumnExpression - let value = Value(queryOutput: row[keyPath: column.keyPath]) - switch value.queryBinding { - case .blob(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .bool(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .double(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .date(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .int(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .null: - removeValue(forKey: column.name, at: userModificationDate) - case .text(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uint(let value): - setValue(value, forKey: column.name, at: userModificationDate) - case .uuid(let value): - setValue( - value.uuidString.lowercased(), - forKey: column.name, - at: userModificationDate - ) - case .invalid(let error): - reportIssue(error) - } + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let column = column as! any WritableTableColumnExpression + let value = Value(queryOutput: row[keyPath: column.keyPath]) + switch value.queryBinding { + case .blob(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .bool(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .double(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .date(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .int(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .null: + removeValue(forKey: column.name, at: userModificationDate) + case .text(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .uint(let value): + setValue(value, forKey: column.name, at: userModificationDate) + case .uuid(let value): + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationDate + ) + case .invalid(let error): + reportIssue(error) } - open(column) } + open(column) } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 63c5616c..829d5f6c 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -854,7 +854,6 @@ } #endif - print("⚠️⚠️⚠️", "changes.count", changes.count) let batch = await syncEngine.recordZoneChangeBatch(pendingChanges: changes) { recordID in var missingTable: CKRecord.ID? var missingRecord: CKRecord.ID? From e44c0305f79f7f06bc887d1bca22da986aa97d71 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 14 Sep 2025 20:10:11 -0500 Subject: [PATCH 3/9] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 829d5f6c..76951e90 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -601,7 +601,6 @@ as: ((String, CKRecord?.SystemFieldsRepresentation) -> Void).self ) func didUpdate(recordName: String, record: CKRecord?) { - print("didUpdate", recordName) let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( CKRecord.ID( From 43136e2c9da6791fa3a2ab239056aa2f9b9a014c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 14 Sep 2025 20:11:52 -0500 Subject: [PATCH 4/9] wip; --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 147 +++++++++---------- 1 file changed, 70 insertions(+), 77 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 76951e90..71245e50 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1259,24 +1259,20 @@ case share(CKShare) case reference(CKShare.Reference) } - let shares = LockIsolated<[ShareOrReference]>([]) - await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - for record in modifications { - if let share = record as? CKShare { - shares.withValue { $0.append(.share(share)) } - } else { - upsertFromServerRecord(record, db: db) - if let shareReference = record.share { - shares.withValue { $0.append(.reference(shareReference)) } - } - } + var shares: [ShareOrReference] = [] + for record in modifications { + if let share = record as? CKShare { + shares.append(.share(share)) + } else { + await upsertFromServerRecord(record) + if let shareReference = record.share { + shares.append(.reference(shareReference)) } } } await withTaskGroup(of: Void.self) { group in - for share in shares.withValue(\.self) { + for share in shares { group.addTask { switch share { case .share(let share): @@ -1499,96 +1495,93 @@ force: Bool = false ) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in - upsertFromServerRecord(serverRecord, force: force, db: db) - } - } - } - - private func upsertFromServerRecord( - _ serverRecord: CKRecord, - force: Bool = false, - db: Database - ) { - withErrorReporting(.sqliteDataCloudKitFailure) { guard let table = tablesByName[serverRecord.recordType] else { guard let recordPrimaryKey = serverRecord.recordID.recordPrimaryKey else { return } - try SyncMetadata.insert { - SyncMetadata( - recordPrimaryKey: recordPrimaryKey, - recordType: serverRecord.recordType, - parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, - parentRecordType: serverRecord.parent?.recordID.tableName, - lastKnownServerRecord: serverRecord, - _lastKnownServerRecordAllFields: serverRecord, - share: nil, - userModificationDate: serverRecord.userModificationDate - ) - } onConflict: { - ($0.recordPrimaryKey, $0.recordType) - } doUpdate: { - $0.setLastKnownServerRecord(serverRecord) + try await userDatabase.write { db in + try SyncMetadata.insert { + SyncMetadata( + recordPrimaryKey: recordPrimaryKey, + recordType: serverRecord.recordType, + parentRecordPrimaryKey: serverRecord.parent?.recordID.recordPrimaryKey, + parentRecordType: serverRecord.parent?.recordID.tableName, + lastKnownServerRecord: serverRecord, + _lastKnownServerRecordAllFields: serverRecord, + share: nil, + userModificationDate: serverRecord.userModificationDate + ) + } onConflict: { + ($0.recordPrimaryKey, $0.recordType) + } doUpdate: { + $0.setLastKnownServerRecord(serverRecord) + } + .execute(db) } - .execute(db) return } - let metadata = try SyncMetadata - .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .fetchOne(db) + let metadata = try await metadatabase.read { db in + try SyncMetadata + .where { $0.recordName.eq(serverRecord.recordID.recordName) } + .fetchOne(db) + } serverRecord.userModificationDate = - metadata?.userModificationDate ?? serverRecord.userModificationDate + metadata?.userModificationDate ?? serverRecord.userModificationDate - func open(_: T.Type) throws { + func open(_: T.Type) async throws { let columnNames: [String] if !force, let metadata, let allFields = metadata._lastKnownServerRecordAllFields { - var _columnNames = T.TableColumns.writableColumns.map(\.name) - let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) - if let row { + columnNames = try await userDatabase.read { db in + var columnNames = T.TableColumns.writableColumns.map(\.name) + let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db) + guard let row + else { + reportIssue( + """ + Local database record could not be found for '\(serverRecord.recordID.recordName)'. + """ + ) + return columnNames + } serverRecord.update( with: allFields, row: T(queryOutput: row), - columnNames: &_columnNames, + columnNames: &columnNames, parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 - ? foreignKeysByTableName[T.tableName]?.first - : nil - ) - } else { - reportIssue( - """ - Local database record could not be found for '\(serverRecord.recordID.recordName)'. - """ + ? foreignKeysByTableName[T.tableName]?.first + : nil ) + return columnNames } - columnNames = _columnNames } else { columnNames = T.TableColumns.writableColumns.map(\.name) } - do { - try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) - try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) - try SyncMetadata - .where { $0.recordName.eq(serverRecord.recordID.recordName) } - .update { $0.setLastKnownServerRecord(serverRecord) } + try await userDatabase.write { db in + do { + try #sql(upsert(T.self, record: serverRecord, columnNames: columnNames)).execute(db) + try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db) + try SyncMetadata + .where { $0.recordName.eq(serverRecord.recordID.recordName) } + .update { $0.setLastKnownServerRecord(serverRecord) } + .execute(db) + } catch { + guard + let error = error as? DatabaseError, + error.resultCode == .SQLITE_CONSTRAINT, + error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY + else { + throw error + } + try UnsyncedRecordID.insert(or: .ignore) { + UnsyncedRecordID(recordID: serverRecord.recordID) + } .execute(db) - } catch { - guard - let error = error as? DatabaseError, - error.resultCode == .SQLITE_CONSTRAINT, - error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY - else { - throw error - } - try UnsyncedRecordID.insert(or: .ignore) { - UnsyncedRecordID(recordID: serverRecord.recordID) } - .execute(db) } } - try open(table) + try await open(table) } } From 8ced16399e0f25cefbbfad2f83e076f64c270845 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 14 Sep 2025 20:16:17 -0500 Subject: [PATCH 5/9] wip --- Examples/Reminders/ReminderRow.swift | 1 - Examples/Reminders/Schema.swift | 23 ++++++++++------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 59c58870..efe509ad 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -36,7 +36,6 @@ struct ReminderRow: View { } var body: some View { - let _ = print("!!!!!", "reminder row", reminder) HStack { HStack(alignment: .firstTextBaseline) { Button(action: completeButtonTapped) { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 373f578f..9f784994 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -55,9 +55,6 @@ struct Reminder: Hashable, Identifiable { } extension Updates { mutating func toggleStatus() { -// self.status = Case(self.status) -// .when(Reminder.Status.incomplete, then: Reminder.Status.completed) -// .else(Reminder.Status.incomplete) self.status = Case(self.status) .when(Reminder.Status.incomplete, then: Reminder.Status.completing) .else(Reminder.Status.incomplete) @@ -336,16 +333,16 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) -// try Reminder.createTemporaryTrigger( -// after: .update { -// $0.status -// } forEachRow: { old, new in -// Values($handleReminderStatusUpdate()) -// } when: { old, new in -// new.status.eq(Reminder.Status.completing) -// } -// ) -// .execute(db) + try Reminder.createTemporaryTrigger( + after: .update { + $0.status + } forEachRow: { old, new in + Values($handleReminderStatusUpdate()) + } when: { old, new in + new.status.eq(Reminder.Status.completing) + } + ) + .execute(db) if context != .live { try db.seedSampleData() From 9220d67076b92c4c0becd2e9c5a4e7f5f8dcb48b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 15 Sep 2025 12:00:20 -0500 Subject: [PATCH 6/9] wip --- Examples/Reminders/Schema.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 9f784994..8635ac20 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -185,7 +185,6 @@ func appDatabase() throws -> any DatabaseWriter { CREATE TABLE "reminders" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, - "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isFlagged" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "notes" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, From b6212e403fcec981f4d23ff8079235889756c541 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 15 Sep 2025 12:00:37 -0500 Subject: [PATCH 7/9] wip --- Examples/Reminders/RemindersDetail.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 3e947119..aee49d67 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -180,7 +180,6 @@ struct RemindersDetailView: View { @State var navigationTitleHeight: CGFloat = 36 var body: some View { - let _ = Self._printChanges() List { header ForEach(model.reminderRows) { row in From ba3e3f94d1ed94b6892091bed7eb249117dfafb8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 15 Sep 2025 10:08:27 -0700 Subject: [PATCH 8/9] Update Examples/Reminders/Schema.swift --- Examples/Reminders/Schema.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 8635ac20..bb1227c2 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -335,9 +335,9 @@ func appDatabase() throws -> any DatabaseWriter { try Reminder.createTemporaryTrigger( after: .update { $0.status - } forEachRow: { old, new in + } forEachRow: { _, _ in Values($handleReminderStatusUpdate()) - } when: { old, new in + } when: { _, new in new.status.eq(Reminder.Status.completing) } ) From f4363987c71370efed9ad1dd8df560608f78ffcd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 15 Sep 2025 12:33:15 -0500 Subject: [PATCH 9/9] fix tests --- .../xcshareddata/swiftpm/Package.resolved | 11 +---------- Examples/Reminders/Schema.swift | 3 ++- Examples/RemindersTests/RemindersDetailsTests.swift | 8 ++++---- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ab9c5d3..3e56ff26 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" : "5bbd661afed2838bb182df109e97091f7e27810d425e696e92232fba72efe73f", + "originHash" : "8b698458b719345f562ad056ad435aa3db5d6e852f96e6ca49566c5d31ffb528", "pins" : [ { "identity" : "combine-schedulers", @@ -154,15 +154,6 @@ "version" : "601.0.1" } }, - { - "identity" : "swift-tagged", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-tagged", - "state" : { - "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", - "version" : "0.10.0" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index bb1227c2..27ea9981 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -358,7 +358,8 @@ func handleReminderStatusUpdate() { $0?.cancel() $0 = Task { @Dependency(\.defaultDatabase) var database - try await Task.sleep(for: .seconds(5)) + @Dependency(\.continuousClock) var clock + try await clock.sleep(for: .seconds(5)) try await database.write { db in try Reminder .where { $0.status.eq(Reminder.Status.completing) } diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index c4f9cd3d..5bec4b80 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -22,12 +22,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000004), dueDate: Date(2009-02-11T23:31:30.000Z), - isCompleted: false, isFlagged: true, notes: "", position: 2, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Haircut" ), remindersList: RemindersList( @@ -44,12 +44,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000005), dueDate: Date(2009-02-13T23:31:30.000Z), - isCompleted: false, isFlagged: false, notes: "Ask about diet", position: 3, priority: .high, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Doctor appointment" ), remindersList: RemindersList( @@ -66,12 +66,12 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000007), dueDate: Date(2009-02-13T23:31:30.000Z), - isCompleted: false, isFlagged: false, notes: "", position: 5, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Buy concert tickets" ), remindersList: RemindersList( @@ -88,7 +88,6 @@ extension BaseTestSuite { reminder: Reminder( id: UUID(00000000-0000-0000-0000-000000000003), dueDate: nil, - isCompleted: false, isFlagged: false, notes: """ Milk @@ -100,6 +99,7 @@ extension BaseTestSuite { position: 1, priority: nil, remindersListID: UUID(00000000-0000-0000-0000-000000000000), + status: .incomplete, title: "Groceries" ), remindersList: RemindersList(