From 105a55fbfb949228e34e838523aa8885ca54915e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 9 Jan 2026 12:19:03 -0800 Subject: [PATCH 01/33] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 5d094197..69e3fa7b 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -13,6 +13,8 @@ import UIKit #endif +extension String: Error {} + /// An object that manages the synchronization of local and remote SQLite data. /// /// See for more information. @@ -99,10 +101,11 @@ repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible, repeat (each T2).TableColumns.PrimaryColumn: WritableTableColumnExpression { + @Dependency(\.context) var context let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier - + (context == .preview ? "preview" : nil) var allTables: [any SynchronizableTable] = [] var allPrivateTables: [any SynchronizableTable] = [] for table in repeat each tables { @@ -113,7 +116,6 @@ } let userDatabase = UserDatabase(database: database) - @Dependency(\.context) var context guard context == .live else { let privateDatabase = MockCloudDatabase(databaseScope: .private) @@ -325,7 +327,10 @@ : URL(filePath: metadatabase.path).lastPathComponent let attachedMetadatabaseName = URL(string: attachedMetadatabasePath)?.lastPathComponent ?? "" - if metadatabaseName != attachedMetadatabaseName { + @Dependency(\.context) var context + if metadatabaseName != attachedMetadatabaseName + && !(context == .preview && attachedMetadatabaseName.isEmpty) + { throw SchemaError( reason: .metadatabaseMismatch( attachedPath: attachedMetadatabasePath, @@ -337,7 +342,6 @@ """ ) } - } else { try #sql( """ @@ -2151,9 +2155,11 @@ /// - Parameter containerIdentifier: The identifier of the CloudKit container used to /// synchronize data. Defaults to the value set in the app's entitlements. public func attachMetadatabase(containerIdentifier: String? = nil) throws { + @Dependency(\.context) var context let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + ?? (context == .preview ? "preview" : nil) guard let containerIdentifier else { throw SyncEngine.SchemaError.noCloudKitContainer From 3969174f632e6ce1d08d9e734af3190c89035c62 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 9 Jan 2026 12:19:26 -0800 Subject: [PATCH 02/33] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 7 ++- .../CloudKitDemo/CountersListFeature.swift | 10 +++- Examples/CloudKitDemo/MyLibrary/.gitignore | 8 +++ Examples/CloudKitDemo/MyLibrary/Package.swift | 35 +++++++++++++ .../Sources/MyLibrary/MyLibrary.swift | 51 +++++++++++++++++++ .../MyLibrary/Sources/MyLibrary/Schema.swift | 7 +++ .../Tests/MyLibraryTests/MyLibraryTests.swift | 6 +++ Examples/CloudKitDemo/Schema.swift | 6 ++- .../xcshareddata/swiftpm/Package.resolved | 20 +------- 9 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 Examples/CloudKitDemo/MyLibrary/.gitignore create mode 100644 Examples/CloudKitDemo/MyLibrary/Package.swift create mode 100644 Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift create mode 100644 Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift create mode 100644 Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index aef2e449..e29700d3 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -5,10 +5,13 @@ import SwiftUI @main struct CloudKitDemoApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + @Dependency(\.context) var context init() { - try! prepareDependencies { - try $0.bootstrapDatabase() + if context == .live { + try! prepareDependencies { + try $0.bootstrapDatabase() + } } } diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 98b904e2..1a1a5c6f 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -1,7 +1,6 @@ import CloudKit import SQLiteData import SwiftUI -import SwiftUINavigation struct CountersListView: View { @FetchAll var counters: [Counter] @@ -107,3 +106,12 @@ struct CounterRow: View { } } } + +#Preview { + let _ = try! prepareDependencies { + try $0.bootstrapDatabase() + } + NavigationStack { + CountersListView() + } +} diff --git a/Examples/CloudKitDemo/MyLibrary/.gitignore b/Examples/CloudKitDemo/MyLibrary/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/Examples/CloudKitDemo/MyLibrary/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/CloudKitDemo/MyLibrary/Package.swift b/Examples/CloudKitDemo/MyLibrary/Package.swift new file mode 100644 index 00000000..48576ee7 --- /dev/null +++ b/Examples/CloudKitDemo/MyLibrary/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MyLibrary", + platforms: [ + .iOS(.v26), + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "MyLibrary", + targets: ["MyLibrary"] + ), + ], + dependencies: [ + .package(path: "../../..") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "MyLibrary", + dependencies: [ + .product(name: "SQLiteData", package: "sqlite-data") + ] + ), + .testTarget( + name: "MyLibraryTests", + dependencies: ["MyLibrary"] + ), + ] +) diff --git a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift new file mode 100644 index 00000000..862147c1 --- /dev/null +++ b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift @@ -0,0 +1,51 @@ +import Dependencies +import Foundation +import GRDB +import SQLiteData + +//@Table +//nonisolated struct Counter: Identifiable { +// let id: UUID +// var count = 0 +//} + +extension DependencyValues { + mutating func bootstrapDatabase() throws { + var configuration = Configuration() +// configuration.prepareDatabase { db in +// try db.attachMetadatabase() +// } +// let database = try SQLiteData.defaultDatabase(configuration: configuration) +// +// var migrator = DatabaseMigrator() +// #if DEBUG +// migrator.eraseDatabaseOnSchemaChange = true +// #endif +// migrator.registerMigration("Create tables") { db in +// try #sql( +// """ +// CREATE TABLE "counters" ( +// "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), +// "count" INT NOT NULL ON CONFLICT REPLACE DEFAULT 0 +// ) STRICT +// """ +// ) +// .execute(db) +// } +// try migrator.migrate(database) +// defaultDatabase = database +// defaultSyncEngine = try SyncEngine( +// for: defaultDatabase, +// tables: Counter.self +// ) + } +} + +import SwiftUI + +#Preview { +// let _ = try! prepareDependencies { +// try $0.bootstrapDatabase() +// } + Text("Ok") +} diff --git a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift new file mode 100644 index 00000000..0ef31ffc --- /dev/null +++ b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift @@ -0,0 +1,7 @@ +// +// Schema.swift +// MyLibrary +// +// Created by Stephen Celis on 1/9/26. +// + diff --git a/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift b/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift new file mode 100644 index 00000000..d6d43bfa --- /dev/null +++ b/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import MyLibrary + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 4721e583..02a8c947 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -11,7 +11,11 @@ nonisolated struct Counter: Identifiable { extension DependencyValues { mutating func bootstrapDatabase() throws { @Dependency(\.context) var context - let database = try SQLiteData.defaultDatabase() + var configuration = Configuration() + configuration.prepareDatabase { db in + try db.attachMetadatabase() + } + let database = try SQLiteData.defaultDatabase(configuration: configuration) logger.debug( """ App database diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4ff76b6f..1f540899 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" : "41e7781e6c506773b6af84af513bcd6d3b1be59d635e6c4c4bd89638368e4629", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "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", From 03a6ff3e76254ef319138bf09cd58783cd9aee47 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 9 Jan 2026 12:25:18 -0800 Subject: [PATCH 03/33] wip --- Examples/CloudKitDemo/MyLibrary/.gitignore | 8 --- Examples/CloudKitDemo/MyLibrary/Package.swift | 35 ------------- .../Sources/MyLibrary/MyLibrary.swift | 51 ------------------- .../MyLibrary/Sources/MyLibrary/Schema.swift | 7 --- .../Tests/MyLibraryTests/MyLibraryTests.swift | 6 --- 5 files changed, 107 deletions(-) delete mode 100644 Examples/CloudKitDemo/MyLibrary/.gitignore delete mode 100644 Examples/CloudKitDemo/MyLibrary/Package.swift delete mode 100644 Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift delete mode 100644 Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift delete mode 100644 Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift diff --git a/Examples/CloudKitDemo/MyLibrary/.gitignore b/Examples/CloudKitDemo/MyLibrary/.gitignore deleted file mode 100644 index 0023a534..00000000 --- a/Examples/CloudKitDemo/MyLibrary/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Examples/CloudKitDemo/MyLibrary/Package.swift b/Examples/CloudKitDemo/MyLibrary/Package.swift deleted file mode 100644 index 48576ee7..00000000 --- a/Examples/CloudKitDemo/MyLibrary/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 6.2 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "MyLibrary", - platforms: [ - .iOS(.v26), - ], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "MyLibrary", - targets: ["MyLibrary"] - ), - ], - dependencies: [ - .package(path: "../../..") - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "MyLibrary", - dependencies: [ - .product(name: "SQLiteData", package: "sqlite-data") - ] - ), - .testTarget( - name: "MyLibraryTests", - dependencies: ["MyLibrary"] - ), - ] -) diff --git a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift deleted file mode 100644 index 862147c1..00000000 --- a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/MyLibrary.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Dependencies -import Foundation -import GRDB -import SQLiteData - -//@Table -//nonisolated struct Counter: Identifiable { -// let id: UUID -// var count = 0 -//} - -extension DependencyValues { - mutating func bootstrapDatabase() throws { - var configuration = Configuration() -// configuration.prepareDatabase { db in -// try db.attachMetadatabase() -// } -// let database = try SQLiteData.defaultDatabase(configuration: configuration) -// -// var migrator = DatabaseMigrator() -// #if DEBUG -// migrator.eraseDatabaseOnSchemaChange = true -// #endif -// migrator.registerMigration("Create tables") { db in -// try #sql( -// """ -// CREATE TABLE "counters" ( -// "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), -// "count" INT NOT NULL ON CONFLICT REPLACE DEFAULT 0 -// ) STRICT -// """ -// ) -// .execute(db) -// } -// try migrator.migrate(database) -// defaultDatabase = database -// defaultSyncEngine = try SyncEngine( -// for: defaultDatabase, -// tables: Counter.self -// ) - } -} - -import SwiftUI - -#Preview { -// let _ = try! prepareDependencies { -// try $0.bootstrapDatabase() -// } - Text("Ok") -} diff --git a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift b/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift deleted file mode 100644 index 0ef31ffc..00000000 --- a/Examples/CloudKitDemo/MyLibrary/Sources/MyLibrary/Schema.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Schema.swift -// MyLibrary -// -// Created by Stephen Celis on 1/9/26. -// - diff --git a/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift b/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift deleted file mode 100644 index d6d43bfa..00000000 --- a/Examples/CloudKitDemo/MyLibrary/Tests/MyLibraryTests/MyLibraryTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import MyLibrary - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} From bef70646ecacf7c4e25dae9a6bad4d9e7828be13 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 9 Jan 2026 12:26:54 -0800 Subject: [PATCH 04/33] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 69e3fa7b..24891aa4 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -13,8 +13,6 @@ import UIKit #endif -extension String: Error {} - /// An object that manages the synchronization of local and remote SQLite data. /// /// See for more information. @@ -105,7 +103,7 @@ extension String: Error {} let containerIdentifier = containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier - (context == .preview ? "preview" : nil) + ?? (context == .preview ? "preview" : nil) var allTables: [any SynchronizableTable] = [] var allPrivateTables: [any SynchronizableTable] = [] for table in repeat each tables { From a6cc565bf1ecfe72525c6fd2a445c2c5eb5d897e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Fri, 9 Jan 2026 12:28:10 -0800 Subject: [PATCH 05/33] wip --- Examples/CloudKitDemo/Schema.swift | 1 - Examples/SyncUps/Schema.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Examples/CloudKitDemo/Schema.swift b/Examples/CloudKitDemo/Schema.swift index 02a8c947..03eaf63c 100644 --- a/Examples/CloudKitDemo/Schema.swift +++ b/Examples/CloudKitDemo/Schema.swift @@ -10,7 +10,6 @@ nonisolated struct Counter: Identifiable { extension DependencyValues { mutating func bootstrapDatabase() throws { - @Dependency(\.context) var context var configuration = Configuration() configuration.prepareDatabase { db in try db.attachMetadatabase() diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 9ac636d2..a3063f5b 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -77,7 +77,6 @@ extension Int { extension DependencyValues { mutating func bootstrapDatabase() throws { - @Dependency(\.context) var context let database = try SQLiteData.defaultDatabase() logger.debug( """ From c8ce17d8ce368ab9d8aa3546567403efa25f4b0f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 15:50:51 -0600 Subject: [PATCH 06/33] Better support for sharing in previews. --- .../CloudKitDemo/CountersListFeature.swift | 45 +++++-- .../xcshareddata/swiftpm/Package.resolved | 20 ++- .../SQLiteData/CloudKit/CloudKitSharing.swift | 93 ++++++++++++- .../CloudKit/Internal/MockCloudDatabase.swift | 6 + .../CloudKit/Internal/MockSyncEngine.swift | 122 ++++++++++++------ .../Internal/SyncEngineProtocol+Live.swift | 26 +++- .../Internal/SyncEngineProtocol.swift | 8 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 36 +++--- 8 files changed, 275 insertions(+), 81 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 1a1a5c6f..23c2a450 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,12 @@ struct CountersListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { - withErrorReporting { - try database.write { db in - try Counter.insert { Counter.Draft() } - .execute(db) + Task { + withErrorReporting { + try database.write { db in + try Counter.insert { Counter.Draft() } + .execute(db) + } } } } @@ -39,7 +53,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 +62,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 +70,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 +95,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 +104,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 +115,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) 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/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 09f4181b..ac7e982d 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -73,8 +73,10 @@ configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { + print(#function, #line) guard isRunning else { + print(#function, #line) throw SharingError( reason: .syncEngineNotRunning, debugDescription: """ @@ -85,6 +87,7 @@ } guard tablesByName[T.tableName] != nil else { + print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -96,6 +99,7 @@ ) } if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { + print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -107,6 +111,7 @@ } guard !privateTables.contains(where: { T.self == $0.base }) else { + print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -118,6 +123,7 @@ ) } let recordName = record.recordName + print(#function, #line) let lastKnownServerRecord = try await metadatabase.read { db in try SyncMetadata @@ -125,8 +131,10 @@ .select(\._lastKnownServerRecordAllFields) .fetchOne(db) } ?? nil + print(#function, #line) guard let lastKnownServerRecord else { + print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -137,6 +145,7 @@ ) } + print(#function, #line) var existingShare: CKShare? { get async throws { let share = try await metadatabase.read { db in @@ -145,14 +154,18 @@ .select(\.share) .fetchOne(db) ?? nil } + print(#function, #line) guard let shareRecordID = share?.recordID else { + print(#function, #line) return nil } do { + print(#function, #line) return try await container.database(for: lastKnownServerRecord.recordID) .record(for: shareRecordID) as? CKShare } catch let error as CKError where error.code == .unknownItem { + print(#function, #line) return nil } } @@ -167,25 +180,32 @@ zoneID: lastKnownServerRecord.recordID.zoneID ) ) + print(#function, #line) configure(sharedRecord) + print(#function, #line) let (saveResults, _) = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, lastKnownServerRecord], deleting: [] ) + print(#function, #line) let savedShare = try saveResults.values.compactMap { result in let record = try result.get() return record.recordID == sharedRecord.recordID ? record as? CKShare : nil } .first + print(#function, #line) let savedRootRecord = try saveResults.values.compactMap { result in let record = try result.get() + print(#function, #line) return record.recordID == lastKnownServerRecord.recordID ? record : nil } .first + print(#function, #line) guard let savedShare, let savedRootRecord else { + print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -195,6 +215,7 @@ """ ) } + print(#function, #line) try await metadatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } @@ -205,6 +226,7 @@ .execute(db) } + print(#function, #line) return SharedRecord(container: container, share: savedShare) } @@ -227,6 +249,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,12 +275,66 @@ /// /// 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 { + Form { + Button("Stop sharing", role: .destructive) { + Task { + try await syncEngine.unshare(share: sharedRecord.share) + try await syncEngine.fetchChanges() + dismiss() + } + } + } + .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 + let didStopSharing: () -> Void + let syncEngine: SyncEngine + @Dependency(\.context) var context public init( sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = [], @@ -281,7 +361,14 @@ ) } - public func makeUIViewController(context: Context) -> UICloudSharingController { + public func makeUIViewController(context: Context) -> UIViewController { + guard self.context == .live + else { + Task { + try await syncEngine.fetchChanges() + } + return UIViewController() + } let controller = UICloudSharingController( share: sharedRecord.share, container: sharedRecord.container.rawValue @@ -292,7 +379,7 @@ } public func updateUIViewController( - _ uiViewController: UICloudSharingController, + _ uiViewController: UIViewController, context: Context ) { } 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..377771c3 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -1,5 +1,6 @@ #if canImport(CloudKit) import CloudKit +import Dependencies import IssueReporting import OrderedCollections @@ -10,15 +11,35 @@ private let _state: LockIsolated package let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) package let _acceptedShareMetadata = LockIsolated>([]) + package let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] + ) + package let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + let context = Dependency(\.context) package init( database: MockCloudDatabase, parentSyncEngine: SyncEngine, - state: MockSyncEngineState + state: MockSyncEngineState, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column ) { self.database = database self.parentSyncEngine = parentSyncEngine self._state = LockIsolated(state) + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column } package var scope: CKDatabase.Scope { @@ -34,7 +55,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,26 +67,38 @@ @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 ) } package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { - if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingDatabaseChanges + if !parentSyncEngine.syncEngine(for: database.databaseScope).pendingDatabaseChanges .isEmpty { try await parentSyncEngine.processPendingDatabaseChanges(scope: database.databaseScope) } - if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges + if !parentSyncEngine.syncEngine(for: database.databaseScope).pendingRecordZoneChanges .isEmpty { @@ -96,7 +129,7 @@ } } - state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) + remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, @@ -106,33 +139,10 @@ package func cancelOperations() async { } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package final class MockSyncEngineState: CKSyncEngineStateProtocol { - package let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] - ) - package let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) - private let fileID: StaticString - private let filePath: StaticString - private let line: UInt - private let column: UInt - package init( - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column - ) { - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column - } + /// ---------- + package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { _pendingRecordZoneChanges.withValue { Array($0) } @@ -148,30 +158,60 @@ } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + guard !pendingRecordZoneChanges.isEmpty + else { return } + self._pendingRecordZoneChanges.withValue { $0.append(contentsOf: pendingRecordZoneChanges) } + + if context.wrappedValue == .preview { + Task { try await parentSyncEngine.sendChanges() } + } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { + guard !pendingRecordZoneChanges.isEmpty + else { return } + self._pendingRecordZoneChanges.withValue { $0.subtract(pendingRecordZoneChanges) } + if context.wrappedValue == .preview { + Task { try await parentSyncEngine.sendChanges() } + } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + guard !pendingDatabaseChanges.isEmpty + else { return } + self._pendingDatabaseChanges.withValue { $0.append(contentsOf: pendingDatabaseChanges) } + if context.wrappedValue == .preview { + Task { try await parentSyncEngine.sendChanges() } + } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { + guard !pendingDatabaseChanges.isEmpty + else { return } + self._pendingDatabaseChanges.withValue { $0.subtract(pendingDatabaseChanges) } + if context.wrappedValue == .preview { + Task { try await parentSyncEngine.sendChanges() } + } } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package final class MockSyncEngineState: CKSyncEngineStateProtocol { + + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { package func processPendingRecordZoneChanges( @@ -184,7 +224,7 @@ column: UInt = #column ) async throws { let syncEngine = syncEngine(for: scope) - guard !syncEngine.state.pendingRecordZoneChanges.isEmpty + guard !syncEngine.pendingRecordZoneChanges.isEmpty else { reportIssue( "Processing empty set of record zone changes.", @@ -264,16 +304,16 @@ fatalError("Mocks should only raise 'CKError' values.") } } - syncEngine.state.remove( + syncEngine.remove( pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } ) - syncEngine.state.remove( + syncEngine.remove( pendingRecordZoneChanges: failedRecordSaves.map { .saveRecord($0.record.recordID) } ) - syncEngine.state.remove( + syncEngine.remove( pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - syncEngine.state.remove( + syncEngine.remove( pendingRecordZoneChanges: failedRecordDeletes.keys.map { .deleteRecord($0) } ) @@ -297,7 +337,7 @@ column: UInt = #column ) async throws { let syncEngine = syncEngine(for: scope) - guard !syncEngine.state.pendingDatabaseChanges.isEmpty + guard !syncEngine.pendingDatabaseChanges.isEmpty else { reportIssue( "Processing empty set of database changes.", @@ -322,7 +362,7 @@ var zonesToSave: [CKRecordZone] = [] var zoneIDsToDelete: [CKRecordZone.ID] = [] - for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges { + for pendingDatabaseChange in syncEngine.pendingDatabaseChanges { switch pendingDatabaseChange { case .saveZone(let zone): zonesToSave.append(zone) @@ -365,8 +405,8 @@ } } - syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) - syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) + syncEngine.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) + syncEngine.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) await syncEngine.parentSyncEngine .handleEvent( diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift index d462e198..1d2f9b2a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift @@ -2,7 +2,31 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension CKSyncEngine: SyncEngineProtocol { +extension CKSyncEngine: SyncEngineProtocol { + package var pendingRecordZoneChanges: [PendingRecordZoneChange] { + state.pendingRecordZoneChanges + } + + package var pendingDatabaseChanges: [PendingDatabaseChange] { + state.pendingDatabaseChanges + } + + package func add(pendingRecordZoneChanges: [PendingRecordZoneChange]) { + state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + } + + package func remove(pendingRecordZoneChanges: [PendingRecordZoneChange]) { + state.remove(pendingRecordZoneChanges: pendingRecordZoneChanges) + } + + package func add(pendingDatabaseChanges: [PendingDatabaseChange]) { + state.add(pendingDatabaseChanges: pendingDatabaseChanges) + } + + package func remove(pendingDatabaseChanges: [PendingDatabaseChange]) { + state.remove(pendingDatabaseChanges: pendingDatabaseChanges) + } + package func recordZoneChangeBatch( pendingChanges: [PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index 58cff003..6a9f6d52 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -16,10 +16,8 @@ recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? ) async -> CKSyncEngine.RecordZoneChangeBatch? func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package protocol CKSyncEngineStateProtocol: Sendable { + var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) @@ -27,4 +25,8 @@ func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol CKSyncEngineStateProtocol: Sendable { + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 24891aa4..60bcf320 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -499,7 +499,7 @@ guard try await container.accountStatus() == .available else { return } syncEngines.withValue { - $0.private?.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + $0.private?.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) } try await uploadRecordsToCloudKit( previousRecordTypeByTableName: previousRecordTypeByTableName, @@ -621,8 +621,8 @@ } } syncEngines.withValue { - $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) - $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + $0.private?.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) + $0.shared?.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } } @@ -807,8 +807,8 @@ let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) - syncEngine?.state.add(pendingRecordZoneChanges: newChanges) + oldSyncEngine?.add(pendingRecordZoneChanges: oldChanges) + syncEngine?.add(pendingRecordZoneChanges: newChanges) } @DatabaseFunction( @@ -845,7 +845,7 @@ let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - syncEngine?.state.add(pendingRecordZoneChanges: changes) + syncEngine?.add(pendingRecordZoneChanges: changes) } package func acceptShare(metadata: ShareMetadata) async throws { @@ -1095,7 +1095,7 @@ ) ?? nil else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil } @@ -1126,7 +1126,7 @@ guard let table = tablesByName[metadata.recordType] else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) missingTable = recordID return nil } @@ -1144,7 +1144,7 @@ ?? nil guard let row else { - syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) missingRecord = recordID return nil } @@ -1186,7 +1186,7 @@ options: CKSyncEngine.SendChangesOptions, syncEngine: any SyncEngineProtocol ) async -> [CKSyncEngine.PendingRecordZoneChange] { - var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) + var changes = syncEngine.pendingRecordZoneChanges.filter(options.scope.contains) guard !changes.isEmpty else { return [] } @@ -1267,7 +1267,7 @@ guard shareRecordIDsToDelete.contains(rootShareRecordID) else { continue } changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID) }) - syncEngine.state.remove( + syncEngine.remove( pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)] ) } @@ -1296,7 +1296,7 @@ switch changeType { case .signIn: - syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + syncEngine.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) await withErrorReporting { try await enqueueUnknownRecordsForCloudKit() } @@ -1360,7 +1360,7 @@ } ?? false if defaultZoneDeleted { - syncEngine.state.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)]) + syncEngine.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)]) } @Sendable func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws { @@ -1410,7 +1410,7 @@ } open(table) } - syncEngine.state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + syncEngine.add(pendingRecordZoneChanges: pendingRecordZoneChanges) } } @@ -1611,8 +1611,8 @@ var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + syncEngine.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for (failedRecord, error) in failedRecordSaves { func clearServerRecord() async { @@ -1763,10 +1763,10 @@ UnsyncedRecordID(recordID: failedRecordID) } .execute(db) - syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + syncEngine.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) break case .batchRequestFailed: - syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + syncEngine.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) break case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .internalError, .partialFailure, From c847dfd41da71af080bb900bc4efab303a69017f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 15:58:00 -0600 Subject: [PATCH 07/33] wip --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index ac7e982d..62a7ebc7 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -73,10 +73,8 @@ configure: @Sendable (CKShare) -> Void ) async throws -> SharedRecord where T.TableColumns.PrimaryKey.QueryOutput: IdentifierStringConvertible { - print(#function, #line) guard isRunning else { - print(#function, #line) throw SharingError( reason: .syncEngineNotRunning, debugDescription: """ @@ -87,7 +85,6 @@ } guard tablesByName[T.tableName] != nil else { - print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -99,7 +96,6 @@ ) } if let foreignKeys = foreignKeysByTableName[T.tableName], !foreignKeys.isEmpty { - print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -111,7 +107,6 @@ } guard !privateTables.contains(where: { T.self == $0.base }) else { - print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -123,7 +118,6 @@ ) } let recordName = record.recordName - print(#function, #line) let lastKnownServerRecord = try await metadatabase.read { db in try SyncMetadata @@ -131,10 +125,8 @@ .select(\._lastKnownServerRecordAllFields) .fetchOne(db) } ?? nil - print(#function, #line) guard let lastKnownServerRecord else { - print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -145,7 +137,6 @@ ) } - print(#function, #line) var existingShare: CKShare? { get async throws { let share = try await metadatabase.read { db in @@ -180,32 +171,25 @@ zoneID: lastKnownServerRecord.recordID.zoneID ) ) - print(#function, #line) configure(sharedRecord) - print(#function, #line) let (saveResults, _) = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, lastKnownServerRecord], deleting: [] ) - print(#function, #line) let savedShare = try saveResults.values.compactMap { result in let record = try result.get() return record.recordID == sharedRecord.recordID ? record as? CKShare : nil } .first - print(#function, #line) let savedRootRecord = try saveResults.values.compactMap { result in let record = try result.get() - print(#function, #line) return record.recordID == lastKnownServerRecord.recordID ? record : nil } .first - print(#function, #line) guard let savedShare, let savedRootRecord else { - print(#function, #line) throw SharingError( recordTableName: T.tableName, recordPrimaryKey: record.primaryKey.rawIdentifier, @@ -215,7 +199,6 @@ """ ) } - print(#function, #line) try await metadatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } @@ -226,7 +209,6 @@ .execute(db) } - print(#function, #line) return SharedRecord(container: container, share: savedShare) } From 2b2deaf244ff70fced99825db396ddc3a2692317 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 15:58:43 -0600 Subject: [PATCH 08/33] wip --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 62a7ebc7..04c11516 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -148,15 +148,12 @@ print(#function, #line) guard let shareRecordID = share?.recordID else { - print(#function, #line) return nil } do { - print(#function, #line) return try await container.database(for: lastKnownServerRecord.recordID) .record(for: shareRecordID) as? CKShare } catch let error as CKError where error.code == .unknownItem { - print(#function, #line) return nil } } From f3c737505a837fda5b50b17b57acc2225caf9f70 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 15:59:56 -0600 Subject: [PATCH 09/33] wip --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 04c11516..1a5a98a7 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -145,7 +145,6 @@ .select(\.share) .fetchOne(db) ?? nil } - print(#function, #line) guard let shareRecordID = share?.recordID else { return nil From 3d15e5e840cc4d18d2074b5011bd1da764e84aed Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 16:12:07 -0600 Subject: [PATCH 10/33] clean up --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 11 +---- .../CloudKit/Internal/MockSyncEngine.swift | 16 ------- .../Internal/SyncEngineProtocol+Live.swift | 42 +++++++++---------- .../Internal/SyncEngineProtocol.swift | 8 +--- Sources/SQLiteData/CloudKit/SyncEngine.swift | 12 +----- .../NextRecordZoneChangeBatchTests.swift | 2 +- .../Internal/BaseCloudKitTests.swift | 18 ++++---- .../Internal/CloudKit+CustomDump.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- 9 files changed, 35 insertions(+), 78 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 1a5a98a7..665a8b96 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -339,14 +339,7 @@ ) } - public func makeUIViewController(context: Context) -> UIViewController { - guard self.context == .live - else { - Task { - try await syncEngine.fetchChanges() - } - return UIViewController() - } + public func makeUIViewController(context: Context) -> UICloudSharingController { let controller = UICloudSharingController( share: sharedRecord.share, container: sharedRecord.container.rawValue @@ -357,7 +350,7 @@ } public func updateUIViewController( - _ uiViewController: UIViewController, + _ uiViewController: UICloudSharingController, context: Context ) { } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 377771c3..bdeea240 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -8,7 +8,6 @@ import Dependencies package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase package let parentSyncEngine: SyncEngine - private let _state: LockIsolated package let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) package let _acceptedShareMetadata = LockIsolated>([]) package let _pendingRecordZoneChanges = LockIsolated< @@ -27,7 +26,6 @@ import Dependencies package init( database: MockCloudDatabase, parentSyncEngine: SyncEngine, - state: MockSyncEngineState, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -35,7 +33,6 @@ import Dependencies ) { self.database = database self.parentSyncEngine = parentSyncEngine - self._state = LockIsolated(state) self.fileID = fileID self.filePath = filePath self.line = line @@ -46,10 +43,6 @@ import Dependencies database.databaseScope } - package var state: MockSyncEngineState { - _state.withValue(\.self) - } - package func acceptShare(metadata: ShareMetadata) { _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } } @@ -140,10 +133,6 @@ import Dependencies package func cancelOperations() async { } - - /// ---------- - - package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { _pendingRecordZoneChanges.withValue { Array($0) } } @@ -207,11 +196,6 @@ import Dependencies } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package final class MockSyncEngineState: CKSyncEngineStateProtocol { - - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { package func processPendingRecordZoneChanges( diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift index 1d2f9b2a..dffc75aa 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift @@ -2,30 +2,30 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension CKSyncEngine: SyncEngineProtocol { - package var pendingRecordZoneChanges: [PendingRecordZoneChange] { - state.pendingRecordZoneChanges - } + extension CKSyncEngine: SyncEngineProtocol { + package var pendingRecordZoneChanges: [PendingRecordZoneChange] { + state.pendingRecordZoneChanges + } - package var pendingDatabaseChanges: [PendingDatabaseChange] { - state.pendingDatabaseChanges - } + package var pendingDatabaseChanges: [PendingDatabaseChange] { + state.pendingDatabaseChanges + } - package func add(pendingRecordZoneChanges: [PendingRecordZoneChange]) { - state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) - } + package func add(pendingRecordZoneChanges: [PendingRecordZoneChange]) { + state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + } - package func remove(pendingRecordZoneChanges: [PendingRecordZoneChange]) { - state.remove(pendingRecordZoneChanges: pendingRecordZoneChanges) - } + package func remove(pendingRecordZoneChanges: [PendingRecordZoneChange]) { + state.remove(pendingRecordZoneChanges: pendingRecordZoneChanges) + } - package func add(pendingDatabaseChanges: [PendingDatabaseChange]) { - state.add(pendingDatabaseChanges: pendingDatabaseChanges) - } + package func add(pendingDatabaseChanges: [PendingDatabaseChange]) { + state.add(pendingDatabaseChanges: pendingDatabaseChanges) + } - package func remove(pendingDatabaseChanges: [PendingDatabaseChange]) { - state.remove(pendingDatabaseChanges: pendingDatabaseChanges) - } + package func remove(pendingDatabaseChanges: [PendingDatabaseChange]) { + state.remove(pendingDatabaseChanges: pendingDatabaseChanges) + } package func recordZoneChangeBatch( pendingChanges: [PendingRecordZoneChange], @@ -35,8 +35,4 @@ extension CKSyncEngine: SyncEngineProtocol { .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) } } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension CKSyncEngine.State: CKSyncEngineStateProtocol { - } #endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index 6a9f6d52..cc26baad 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -2,12 +2,10 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package protocol SyncEngineProtocol: AnyObject, Sendable { - associatedtype State: CKSyncEngineStateProtocol + package protocol SyncEngineProtocol: AnyObject, Sendable { associatedtype Database: CloudDatabase var database: Database { get } - var state: State { get } func cancelOperations() async func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws @@ -25,8 +23,4 @@ func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package protocol CKSyncEngineStateProtocol: Sendable { - } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 60bcf320..1cc39a19 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -130,16 +130,8 @@ defaultZone: defaultZone, defaultSyncEngines: { _, syncEngine in ( - private: MockSyncEngine( - database: privateDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() - ), - shared: MockSyncEngine( - database: sharedDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() - ) + private: MockSyncEngine(database: privateDatabase, parentSyncEngine: syncEngine), + shared: MockSyncEngine(database: sharedDatabase, parentSyncEngine: syncEngine) ) }, userDatabase: userDatabase, diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index b36cbd8a..668bbea7 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -13,7 +13,7 @@ final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noMetadataForRecord() async throws { - syncEngine.private.state.add( + syncEngine.private.add( pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 4aec1bc8..305b2152 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -112,8 +112,8 @@ class BaseCloudKitTests: @unchecked Sendable { func signIn() async { container._accountStatus.withValue { $0 = .available } // NB: Emulates what CKSyncEngine does when signing in - syncEngine.private.state.removePendingChanges() - syncEngine.shared.state.removePendingChanges() + syncEngine.private.removePendingChanges() + syncEngine.shared.removePendingChanges() await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -130,12 +130,12 @@ class BaseCloudKitTests: @unchecked Sendable { else { return } syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.state.assertPendingDatabaseChanges([]) - syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertPendingDatabaseChanges([]) + syncEngine.shared.assertPendingRecordZoneChanges([]) syncEngine.shared.assertAcceptedShareMetadata([]) syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.state.assertPendingDatabaseChanges([]) - syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertPendingDatabaseChanges([]) + syncEngine.private.assertPendingRecordZoneChanges([]) syncEngine.private.assertAcceptedShareMetadata([]) try! syncEngine.metadatabase.read { db in @@ -207,13 +207,11 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() + parentSyncEngine: syncEngine ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() + parentSyncEngine: syncEngine ) ) }, diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 5874e5d6..aad1428a 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -157,7 +157,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension MockSyncEngineState: CustomDumpReflectable { + extension MockSyncEngine: CustomDumpReflectable { package var customDumpMirror: Mirror { return Mirror( self, diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 207fd4c8..9facf224 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -153,7 +153,7 @@ extension MockSyncEngine { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension MockSyncEngineState { +extension MockSyncEngine { package func assertPendingRecordZoneChanges( _ changes: OrderedSet, fileID: StaticString = #fileID, From 3b686f1d682f352922628dfe3c9ef72a64bcf3d8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 16:18:22 -0600 Subject: [PATCH 11/33] wip --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 665a8b96..7d12bd58 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -288,11 +288,24 @@ ) } else { Form { - Button("Stop sharing", role: .destructive) { - Task { - try await syncEngine.unshare(share: sharedRecord.share) - try await syncEngine.fetchChanges() - dismiss() + 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() + } } } } From 7ae4728561a8ba52266b262771cfd61fa8fcefd7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 16:19:10 -0600 Subject: [PATCH 12/33] clean up --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 7d12bd58..7006bddf 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -325,7 +325,6 @@ let didFinish: (Result) -> Void let didStopSharing: () -> Void let syncEngine: SyncEngine - @Dependency(\.context) var context public init( sharedRecord: SharedRecord, availablePermissions: UICloudSharingController.PermissionOptions = [], From 0066e5d0f5283ab7bc87fa8a54f873e3446ceca9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 17:48:50 -0600 Subject: [PATCH 13/33] Trying out database pool for previews. --- Examples/CloudKitDemo/CountersListFeature.swift | 9 ++++++++- Examples/Examples.xcodeproj/project.pbxproj | 14 +++++++------- .../CloudKit/Internal/Metadatabase.swift | 6 ++---- .../StructuredQueries+GRDB/DefaultDatabase.swift | 5 ++++- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 23c2a450..8716987d 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -39,7 +39,14 @@ struct CountersListView: View { Task { withErrorReporting { try database.write { db in - try Counter.insert { Counter.Draft() } + try Counter.insert { + Counter.Draft() + Counter.Draft() + Counter.Draft() + Counter.Draft() + Counter.Draft() + Counter.Draft() + } .execute(db) } } diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 8e77d2ed..227adb49 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; - CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2B2E71C472000974D3 /* SQLiteData */; }; CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2D2E71C479000974D3 /* SQLiteData */; }; CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2F2E71C480000974D3 /* SQLiteData */; }; @@ -18,6 +17,7 @@ CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; + CAF2E0252F11C87A00F4E40B /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CAF2E0242F11C87A00F4E40B /* SQLiteData */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; @@ -148,7 +148,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */, + CAF2E0252F11C87A00F4E40B /* SQLiteData in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -268,7 +268,7 @@ ); name = CloudKitDemo; packageProductDependencies = ( - CA2BDE292E71C469000974D3 /* SQLiteData */, + CAF2E0242F11C87A00F4E40B /* SQLiteData */, ); productName = CloudKitDemo; productReference = CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */; @@ -1208,10 +1208,6 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; - CA2BDE292E71C469000974D3 /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - productName = SQLiteData; - }; CA2BDE2B2E71C472000974D3 /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; @@ -1252,6 +1248,10 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesTestSupport; }; + CAF2E0242F11C87A00F4E40B /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; DC5FA7472D4C63D60082743E /* DependenciesMacros */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 8ea33c42..b54da514 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -26,11 +26,9 @@ } let metadatabase: any DatabaseWriter = - if url.isInMemory { - try DatabaseQueue(path: url.absoluteString) - } else { + try DatabasePool(path: url.path(percentEncoded: false)) - } + try migrate(metadatabase: metadatabase) return metadatabase } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift index 63beb0fe..26876c20 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -34,7 +34,10 @@ public func defaultDatabase( } database = try DatabasePool(path: path ?? defaultPath, configuration: configuration) case .preview: - database = try DatabaseQueue(configuration: configuration) + database = try DatabasePool( + path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", + configuration: configuration + ) case .test: database = try DatabasePool( path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", From e673c670fa3e05268e9b48c035fde8ac0748069b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 9 Jan 2026 18:01:39 -0600 Subject: [PATCH 14/33] wip --- Examples/CloudKitDemo/CloudKitDemoApp.swift | 7 ++++-- .../CloudKit/Internal/Metadatabase.swift | 5 ++++ .../CloudKit/Internal/MockSyncEngine.swift | 24 +++++++++---------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 13 ++++++++++ .../DefaultDatabase.swift | 5 ++++ 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index e29700d3..ac1af7eb 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -8,11 +8,14 @@ struct CloudKitDemoApp: App { @Dependency(\.context) var context init() { - if context == .live { +// if context == .live { + try! prepareDependencies { + $0.context = .preview + } try! prepareDependencies { try $0.bootstrapDatabase() } - } +// } } var body: some Scene { diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index b54da514..e8c9c2fe 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -25,6 +25,11 @@ throw InMemoryDatabase() } + var configuration = Configuration() + configuration.busyMode = .callback({ numberOfTries in + print("numberOfTries", numberOfTries) + return numberOfTries < 100 + }) let metadatabase: any DatabaseWriter = try DatabasePool(path: url.path(percentEncoded: false)) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index bdeea240..2d101487 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -154,9 +154,9 @@ import Dependencies $0.append(contentsOf: pendingRecordZoneChanges) } - if context.wrappedValue == .preview { - Task { try await parentSyncEngine.sendChanges() } - } +// if context.wrappedValue == .preview { +// Task { try await parentSyncEngine.sendChanges() } +// } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { @@ -166,9 +166,9 @@ import Dependencies self._pendingRecordZoneChanges.withValue { $0.subtract(pendingRecordZoneChanges) } - if context.wrappedValue == .preview { - Task { try await parentSyncEngine.sendChanges() } - } +// if context.wrappedValue == .preview { +// Task { try await parentSyncEngine.sendChanges() } +// } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { @@ -178,9 +178,9 @@ import Dependencies self._pendingDatabaseChanges.withValue { $0.append(contentsOf: pendingDatabaseChanges) } - if context.wrappedValue == .preview { - Task { try await parentSyncEngine.sendChanges() } - } +// if context.wrappedValue == .preview { +// Task { try await parentSyncEngine.sendChanges() } +// } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { @@ -190,9 +190,9 @@ import Dependencies self._pendingDatabaseChanges.withValue { $0.subtract(pendingDatabaseChanges) } - if context.wrappedValue == .preview { - Task { try await parentSyncEngine.sendChanges() } - } +// if context.wrappedValue == .preview { +// Task { try await parentSyncEngine.sendChanges() } +// } } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 1cc39a19..3c57dad3 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -411,6 +411,7 @@ /// All edits made after stopping the sync engine will not be synchronized to CloudKit. /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { + timerTask?.cancel() guard isRunning else { return } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { @@ -427,7 +428,19 @@ } } + nonisolated(unsafe) var timerTask: Task? + private func start() throws -> Task { + timerTask?.cancel() + @Dependency(\.context) var context + if context == .preview { + timerTask = Task { + while true { + try await Task.sleep(for: .seconds(1)) + try await self.syncChanges() + } + } + } guard !isRunning else { return Task {} } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift index 26876c20..1e403b06 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -34,6 +34,11 @@ public func defaultDatabase( } database = try DatabasePool(path: path ?? defaultPath, configuration: configuration) case .preview: + var configuration = configuration + configuration.busyMode = .callback({ numberOfTries in + print("numberOfTries", numberOfTries) + return numberOfTries < 100 + }) database = try DatabasePool( path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", configuration: configuration From c04f60fce3341fc4e879482de73671e9193f4dce Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:21:43 -0600 Subject: [PATCH 15/33] wip --- .../xcshareddata/swiftpm/Package.resolved | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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", From 253d3aed68b3ecaadfeb33487c549cbcb7758362 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:24:36 -0600 Subject: [PATCH 16/33] clean up --- Examples/CloudKitDemo/CountersListFeature.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 8716987d..93428eba 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -41,13 +41,8 @@ struct CountersListView: View { try database.write { db in try Counter.insert { Counter.Draft() - Counter.Draft() - Counter.Draft() - Counter.Draft() - Counter.Draft() - Counter.Draft() } - .execute(db) + .execute(db) } } } From 044368f6a7779308edc58fe71478c9fd5492dac6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:42:12 -0600 Subject: [PATCH 17/33] clean up --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 1 + .../CloudKit/Internal/Metadatabase.swift | 10 +- .../CloudKit/Internal/MockSyncEngine.swift | 105 ++++++++---------- .../Internal/SyncEngineProtocol+Live.swift | 28 +---- .../Internal/SyncEngineProtocol.swift | 8 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 94 ++++++++++------ .../DefaultDatabase.swift | 12 +- .../NextRecordZoneChangeBatchTests.swift | 2 +- .../Internal/BaseCloudKitTests.swift | 18 +-- .../Internal/CloudKit+CustomDump.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- 11 files changed, 129 insertions(+), 153 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 7006bddf..4d817348 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -309,6 +309,7 @@ } } } + .navigationTitle("Share") .task { await withErrorReporting { try await syncEngine.fetchChanges() diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index e8c9c2fe..c8efa856 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -25,15 +25,7 @@ throw InMemoryDatabase() } - var configuration = Configuration() - configuration.busyMode = .callback({ numberOfTries in - print("numberOfTries", numberOfTries) - return numberOfTries < 100 - }) - let metadatabase: any DatabaseWriter = - - 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/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 2d101487..24d6587f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit -import Dependencies import IssueReporting import OrderedCollections @@ -8,41 +7,28 @@ import Dependencies package final class MockSyncEngine: SyncEngineProtocol { package let database: MockCloudDatabase package let parentSyncEngine: SyncEngine + private let _state: LockIsolated package let _fetchChangesScopes = LockIsolated<[CKSyncEngine.FetchChangesOptions.Scope]>([]) package let _acceptedShareMetadata = LockIsolated>([]) - package let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] - ) - package let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) - private let fileID: StaticString - private let filePath: StaticString - private let line: UInt - private let column: UInt - let context = Dependency(\.context) package init( database: MockCloudDatabase, parentSyncEngine: SyncEngine, - fileID: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column: UInt = #column + state: MockSyncEngineState ) { self.database = database self.parentSyncEngine = parentSyncEngine - self.fileID = fileID - self.filePath = filePath - self.line = line - self.column = column + self._state = LockIsolated(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) } } @@ -65,7 +51,6 @@ import Dependencies ($0[zoneID]?.records.values).map { Array($0) } ?? [] } } - await parentSyncEngine.handleEvent( .fetchedRecordZoneChanges( modifications: modifications, @@ -85,13 +70,13 @@ import Dependencies package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { - if !parentSyncEngine.syncEngine(for: database.databaseScope).pendingDatabaseChanges + if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingDatabaseChanges .isEmpty { try await parentSyncEngine.processPendingDatabaseChanges(scope: database.databaseScope) } - if !parentSyncEngine.syncEngine(for: database.databaseScope).pendingRecordZoneChanges + if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges .isEmpty { @@ -122,7 +107,7 @@ import Dependencies } } - remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) + state.remove(pendingRecordZoneChanges: recordsToSave.map { .saveRecord($0.recordID) }) return CKSyncEngine.RecordZoneChangeBatch( recordsToSave: recordsToSave, @@ -132,6 +117,33 @@ import Dependencies package func cancelOperations() async { } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package final class MockSyncEngineState: CKSyncEngineStateProtocol { + package let _pendingRecordZoneChanges = LockIsolated< + OrderedSet + >([] + ) + package let _pendingDatabaseChanges = LockIsolated< + OrderedSet + >([]) + private let fileID: StaticString + private let filePath: StaticString + private let line: UInt + private let column: UInt + + package init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { _pendingRecordZoneChanges.withValue { Array($0) } @@ -147,52 +159,27 @@ import Dependencies } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - guard !pendingRecordZoneChanges.isEmpty - else { return } - self._pendingRecordZoneChanges.withValue { $0.append(contentsOf: pendingRecordZoneChanges) } - -// if context.wrappedValue == .preview { -// Task { try await parentSyncEngine.sendChanges() } -// } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - guard !pendingRecordZoneChanges.isEmpty - else { return } - self._pendingRecordZoneChanges.withValue { $0.subtract(pendingRecordZoneChanges) } -// if context.wrappedValue == .preview { -// Task { try await parentSyncEngine.sendChanges() } -// } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - guard !pendingDatabaseChanges.isEmpty - else { return } - self._pendingDatabaseChanges.withValue { $0.append(contentsOf: pendingDatabaseChanges) } -// if context.wrappedValue == .preview { -// Task { try await parentSyncEngine.sendChanges() } -// } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - guard !pendingDatabaseChanges.isEmpty - else { return } - self._pendingDatabaseChanges.withValue { $0.subtract(pendingDatabaseChanges) } -// if context.wrappedValue == .preview { -// Task { try await parentSyncEngine.sendChanges() } -// } } } @@ -208,7 +195,7 @@ import Dependencies column: UInt = #column ) async throws { let syncEngine = syncEngine(for: scope) - guard !syncEngine.pendingRecordZoneChanges.isEmpty + guard !syncEngine.state.pendingRecordZoneChanges.isEmpty else { reportIssue( "Processing empty set of record zone changes.", @@ -288,16 +275,16 @@ import Dependencies fatalError("Mocks should only raise 'CKError' values.") } } - syncEngine.remove( + syncEngine.state.remove( pendingRecordZoneChanges: savedRecords.map { .saveRecord($0.recordID) } ) - syncEngine.remove( + syncEngine.state.remove( pendingRecordZoneChanges: failedRecordSaves.map { .saveRecord($0.record.recordID) } ) - syncEngine.remove( + syncEngine.state.remove( pendingRecordZoneChanges: deletedRecordIDs.map { .deleteRecord($0) } ) - syncEngine.remove( + syncEngine.state.remove( pendingRecordZoneChanges: failedRecordDeletes.keys.map { .deleteRecord($0) } ) @@ -321,7 +308,7 @@ import Dependencies column: UInt = #column ) async throws { let syncEngine = syncEngine(for: scope) - guard !syncEngine.pendingDatabaseChanges.isEmpty + guard !syncEngine.state.pendingDatabaseChanges.isEmpty else { reportIssue( "Processing empty set of database changes.", @@ -346,7 +333,7 @@ import Dependencies var zonesToSave: [CKRecordZone] = [] var zoneIDsToDelete: [CKRecordZone.ID] = [] - for pendingDatabaseChange in syncEngine.pendingDatabaseChanges { + for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges { switch pendingDatabaseChange { case .saveZone(let zone): zonesToSave.append(zone) @@ -389,8 +376,8 @@ import Dependencies } } - syncEngine.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) - syncEngine.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) + syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) + syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) await syncEngine.parentSyncEngine .handleEvent( diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift index dffc75aa..d462e198 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol+Live.swift @@ -3,30 +3,6 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension CKSyncEngine: SyncEngineProtocol { - package var pendingRecordZoneChanges: [PendingRecordZoneChange] { - state.pendingRecordZoneChanges - } - - package var pendingDatabaseChanges: [PendingDatabaseChange] { - state.pendingDatabaseChanges - } - - package func add(pendingRecordZoneChanges: [PendingRecordZoneChange]) { - state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) - } - - package func remove(pendingRecordZoneChanges: [PendingRecordZoneChange]) { - state.remove(pendingRecordZoneChanges: pendingRecordZoneChanges) - } - - package func add(pendingDatabaseChanges: [PendingDatabaseChange]) { - state.add(pendingDatabaseChanges: pendingDatabaseChanges) - } - - package func remove(pendingDatabaseChanges: [PendingDatabaseChange]) { - state.remove(pendingDatabaseChanges: pendingDatabaseChanges) - } - package func recordZoneChangeBatch( pendingChanges: [PendingRecordZoneChange], recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? @@ -35,4 +11,8 @@ .RecordZoneChangeBatch(pendingChanges: pendingChanges, recordProvider: recordProvider) } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension CKSyncEngine.State: CKSyncEngineStateProtocol { + } #endif diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index cc26baad..58cff003 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -2,10 +2,12 @@ import CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - package protocol SyncEngineProtocol: AnyObject, Sendable { + package protocol SyncEngineProtocol: AnyObject, Sendable { + associatedtype State: CKSyncEngineStateProtocol associatedtype Database: CloudDatabase var database: Database { get } + var state: State { get } func cancelOperations() async func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws @@ -14,8 +16,10 @@ recordProvider: @Sendable (CKRecord.ID) async -> CKRecord? ) async -> CKSyncEngine.RecordZoneChangeBatch? func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws + } - + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + package protocol CKSyncEngineStateProtocol: Sendable { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 3c57dad3..7782b337 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. @@ -130,8 +133,16 @@ defaultZone: defaultZone, defaultSyncEngines: { _, syncEngine in ( - private: MockSyncEngine(database: privateDatabase, parentSyncEngine: syncEngine), - shared: MockSyncEngine(database: sharedDatabase, parentSyncEngine: syncEngine) + private: MockSyncEngine( + database: privateDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ), + shared: MockSyncEngine( + database: sharedDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ) ) }, userDatabase: userDatabase, @@ -411,7 +422,6 @@ /// All edits made after stopping the sync engine will not be synchronized to CloudKit. /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { - timerTask?.cancel() guard isRunning else { return } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { @@ -428,19 +438,7 @@ } } - nonisolated(unsafe) var timerTask: Task? - private func start() throws -> Task { - timerTask?.cancel() - @Dependency(\.context) var context - if context == .preview { - timerTask = Task { - while true { - try await Task.sleep(for: .seconds(1)) - try await self.syncChanges() - } - } - } guard !isRunning else { return Task {} } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { @@ -499,12 +497,29 @@ } ) +#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 else { return } syncEngines.withValue { - $0.private?.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + $0.private?.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) } try await uploadRecordsToCloudKit( previousRecordTypeByTableName: previousRecordTypeByTableName, @@ -517,7 +532,10 @@ try await cacheUserTables(recordTypes: currentRecordTypes) } } - self.startTask.withValue { $0 = startTask } + self.startTask.withValue { + $0?.cancel() + $0 = startTask + } return startTask } @@ -626,8 +644,8 @@ } } syncEngines.withValue { - $0.private?.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) - $0.shared?.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } } @@ -812,8 +830,8 @@ let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - oldSyncEngine?.add(pendingRecordZoneChanges: oldChanges) - syncEngine?.add(pendingRecordZoneChanges: newChanges) + oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) + syncEngine?.state.add(pendingRecordZoneChanges: newChanges) } @DatabaseFunction( @@ -850,7 +868,7 @@ let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - syncEngine?.add(pendingRecordZoneChanges: changes) + syncEngine?.state.add(pendingRecordZoneChanges: changes) } package func acceptShare(metadata: ShareMetadata) async throws { @@ -1100,7 +1118,7 @@ ) ?? nil else { - syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) return nil } @@ -1131,7 +1149,7 @@ guard let table = tablesByName[metadata.recordType] else { - syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) missingTable = recordID return nil } @@ -1149,7 +1167,7 @@ ?? nil guard let row else { - syncEngine.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) + syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) missingRecord = recordID return nil } @@ -1191,7 +1209,7 @@ options: CKSyncEngine.SendChangesOptions, syncEngine: any SyncEngineProtocol ) async -> [CKSyncEngine.PendingRecordZoneChange] { - var changes = syncEngine.pendingRecordZoneChanges.filter(options.scope.contains) + var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) guard !changes.isEmpty else { return [] } @@ -1272,7 +1290,7 @@ guard shareRecordIDsToDelete.contains(rootShareRecordID) else { continue } changes.removeAll(where: { $0 == .deleteRecord(lastKnownServerRecord.recordID) }) - syncEngine.remove( + syncEngine.state.remove( pendingRecordZoneChanges: [.deleteRecord(lastKnownServerRecord.recordID)] ) } @@ -1301,7 +1319,7 @@ switch changeType { case .signIn: - syncEngine.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) + syncEngine.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) await withErrorReporting { try await enqueueUnknownRecordsForCloudKit() } @@ -1365,7 +1383,7 @@ } ?? false if defaultZoneDeleted { - syncEngine.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)]) + syncEngine.state.add(pendingDatabaseChanges: [.saveZone(self.defaultZone)]) } @Sendable func deleteRecords(in zoneID: CKRecordZone.ID, db: Database) throws { @@ -1415,7 +1433,7 @@ } open(table) } - syncEngine.add(pendingRecordZoneChanges: pendingRecordZoneChanges) + syncEngine.state.add(pendingRecordZoneChanges: pendingRecordZoneChanges) } } @@ -1507,7 +1525,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)) @@ -1591,7 +1610,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 @@ -1616,8 +1635,8 @@ var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = [] var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = [] defer { - syncEngine.add(pendingDatabaseChanges: newPendingDatabaseChanges) - syncEngine.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) + syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges) + syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges) } for (failedRecord, error) in failedRecordSaves { func clearServerRecord() async { @@ -1768,10 +1787,10 @@ UnsyncedRecordID(recordID: failedRecordID) } .execute(db) - syncEngine.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + syncEngine.state.remove(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) break case .batchRequestFailed: - syncEngine.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) + syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(failedRecordID)]) break case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .internalError, .partialFailure, @@ -2333,7 +2352,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 1e403b06..31a0cf13 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -33,17 +33,7 @@ public func defaultDatabase( } } database = try DatabasePool(path: path ?? defaultPath, configuration: configuration) - case .preview: - var configuration = configuration - configuration.busyMode = .callback({ numberOfTries in - print("numberOfTries", numberOfTries) - return numberOfTries < 100 - }) - database = try DatabasePool( - path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", - configuration: configuration - ) - case .test: + case .preview, .test: database = try DatabasePool( path: "\(NSTemporaryDirectory())\(UUID().uuidString).db", configuration: configuration diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 668bbea7..b36cbd8a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -13,7 +13,7 @@ final class NextRecordZoneChangeBatchTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noMetadataForRecord() async throws { - syncEngine.private.add( + syncEngine.private.state.add( pendingRecordZoneChanges: [.saveRecord(Reminder.recordID(for: 1))] ) diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 305b2152..4aec1bc8 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -112,8 +112,8 @@ class BaseCloudKitTests: @unchecked Sendable { func signIn() async { container._accountStatus.withValue { $0 = .available } // NB: Emulates what CKSyncEngine does when signing in - syncEngine.private.removePendingChanges() - syncEngine.shared.removePendingChanges() + syncEngine.private.state.removePendingChanges() + syncEngine.shared.state.removePendingChanges() await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -130,12 +130,12 @@ class BaseCloudKitTests: @unchecked Sendable { else { return } syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.assertPendingDatabaseChanges([]) - syncEngine.shared.assertPendingRecordZoneChanges([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) syncEngine.shared.assertAcceptedShareMetadata([]) syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.assertPendingDatabaseChanges([]) - syncEngine.private.assertPendingRecordZoneChanges([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) syncEngine.private.assertAcceptedShareMetadata([]) try! syncEngine.metadatabase.read { db in @@ -207,11 +207,13 @@ extension SyncEngine { ( MockSyncEngine( database: container.privateCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine + parentSyncEngine: syncEngine, + state: MockSyncEngineState() ), MockSyncEngine( database: container.sharedCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine + parentSyncEngine: syncEngine, + state: MockSyncEngineState() ) ) }, diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index aad1428a..5874e5d6 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -157,7 +157,7 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension MockSyncEngine: CustomDumpReflectable { + extension MockSyncEngineState: CustomDumpReflectable { package var customDumpMirror: Mirror { return Mirror( self, diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 9facf224..207fd4c8 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -153,7 +153,7 @@ extension MockSyncEngine { } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension MockSyncEngine { +extension MockSyncEngineState { package func assertPendingRecordZoneChanges( _ changes: OrderedSet, fileID: StaticString = #fileID, From 1165fb849fa63d992c1332758e73ece5e1f53217 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:43:51 -0600 Subject: [PATCH 18/33] reformat --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 7782b337..59bd39e8 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -38,8 +38,8 @@ 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) + #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 @@ -497,23 +497,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() + #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 + #endif let startTask = Task { await withErrorReporting(.sqliteDataCloudKitFailure) { guard try await container.accountStatus() == .available From 9d49470d99f6313695390efe084926933d252946 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:44:42 -0600 Subject: [PATCH 19/33] cancel timer when stopping sync engine --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 59bd39e8..f18d762d 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -423,6 +423,10 @@ /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { guard isRunning else { return } + previewTimerTask.withValue { + $0?.cancel() + $0 = nil + } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { $0 = SyncEngines() From 8ed34cdcba69ffa279b85fdf751c4b412ff6d3bf Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 09:51:37 -0600 Subject: [PATCH 20/33] clean up --- Examples/Examples.xcodeproj/project.pbxproj | 14 ++++++------- .../xcshareddata/swiftpm/Package.resolved | 20 +------------------ 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 227adb49..8e77d2ed 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE292E71C469000974D3 /* SQLiteData */; }; CA2BDE2C2E71C472000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2B2E71C472000974D3 /* SQLiteData */; }; CA2BDE2E2E71C479000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2D2E71C479000974D3 /* SQLiteData */; }; CA2BDE302E71C480000974D3 /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA2BDE2F2E71C480000974D3 /* SQLiteData */; }; @@ -17,7 +18,6 @@ CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; - CAF2E0252F11C87A00F4E40B /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CAF2E0242F11C87A00F4E40B /* SQLiteData */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; @@ -148,7 +148,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAF2E0252F11C87A00F4E40B /* SQLiteData in Frameworks */, + CA2BDE2A2E71C469000974D3 /* SQLiteData in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -268,7 +268,7 @@ ); name = CloudKitDemo; packageProductDependencies = ( - CAF2E0242F11C87A00F4E40B /* SQLiteData */, + CA2BDE292E71C469000974D3 /* SQLiteData */, ); productName = CloudKitDemo; productReference = CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */; @@ -1208,6 +1208,10 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CA2BDE292E71C469000974D3 /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + productName = SQLiteData; + }; CA2BDE2B2E71C472000974D3 /* SQLiteData */ = { isa = XCSwiftPackageProductDependency; package = CA2BDE282E71C469000974D3 /* XCLocalSwiftPackageReference ".." */; @@ -1248,10 +1252,6 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesTestSupport; }; - CAF2E0242F11C87A00F4E40B /* SQLiteData */ = { - isa = XCSwiftPackageProductDependency; - productName = SQLiteData; - }; DC5FA7472D4C63D60082743E /* DependenciesMacros */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 94b21e03..1f540899 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" : "82b8ac0230a95cb2adcecfb43731949871837d431deb3b5df27bf186c483fea2", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "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", From 8f556ee1988fe8a1ee32f35f9006a6840a6be590 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 10:09:28 -0600 Subject: [PATCH 21/33] wip --- .../CloudKitTests/PreviewTests.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift 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 From ca01d183622d6b3ad5055935e34d16175b588769 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 10 Jan 2026 10:11:20 -0600 Subject: [PATCH 22/33] wip --- .../xcshareddata/swiftpm/Package.resolved | 20 ++++++++++++++++++- Sources/SQLiteData/CloudKit/SyncEngine.swift | 10 ++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) 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/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f18d762d..13f70ddf 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -423,10 +423,12 @@ /// You must start the sync engine again using ``start()`` to synchronize the changes. public func stop() { guard isRunning else { return } - previewTimerTask.withValue { - $0?.cancel() - $0 = nil - } + #if canImport(DeveloperToolsSupport) + previewTimerTask.withValue { + $0?.cancel() + $0 = nil + } + #endif observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { syncEngines.withValue { $0 = SyncEngines() From eae64d1d7420a0de88d2fbbd12c108c45eb74087 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 10:13:02 -0600 Subject: [PATCH 23/33] wip --- Examples/Reminders/RemindersLists.swift | 7 +- Examples/Reminders/Schema.swift | 246 +++++++++--------- Examples/Reminders/SearchReminders.swift | 3 +- Examples/Reminders/TagsForm.swift | 4 +- Examples/RemindersTests/Internal.swift | 1 + .../RemindersTests/SearchRemindersTests.swift | 102 ++++---- .../SQLiteData/CloudKit/CloudKitSharing.swift | 38 +-- 7 files changed, 201 insertions(+), 200 deletions(-) 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/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 4d817348..ed08b520 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -287,29 +287,31 @@ syncEngine: syncEngine ) } else { - 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) + 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() + Section { + Button("Stop sharing", role: .destructive) { + Task { + try await syncEngine.unshare(share: sharedRecord.share) + try await syncEngine.fetchChanges() + dismiss() + } } } } + .navigationTitle("Share") } - .navigationTitle("Share") .task { await withErrorReporting { try await syncEngine.fetchChanges() From ba92711839da89e4a42ed8a3ac9ab2821758435b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 11:20:23 -0600 Subject: [PATCH 24/33] clean up --- .../CloudKitDemo/CountersListFeature.swift | 1 + Examples/CloudKitDemo/Schema.swift | 13 +++++ Examples/SyncUps/App.swift | 5 +- Examples/SyncUps/Schema.swift | 54 ++++++++++--------- Examples/SyncUps/SyncUpDetail.swift | 3 +- Examples/SyncUps/SyncUpsList.swift | 5 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 6 ++- 7 files changed, 54 insertions(+), 33 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 93428eba..ef764b0b 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -129,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/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/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 13f70ddf..9e5bb0d7 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1789,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)]) @@ -1955,8 +1956,9 @@ else { throw error } - try UnsyncedRecordID.insert(or: .ignore) { + try UnsyncedRecordID.insert { UnsyncedRecordID(recordID: serverRecord.recordID) + } onConflictDoUpdate: { _ in } .execute(db) } From 22b98c775c67b96bc55c0b546f6f6cf16a29a9c0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 11:40:05 -0600 Subject: [PATCH 25/33] wip --- .../RemindersTests/SearchRemindersTests.swift | 102 +++++++++--------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 02009fe7..8faeceb1 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -67,60 +67,62 @@ extension BaseTestSuite { let model = SearchRemindersModel() model.searchText = "Take" try await model.showCompletedButtonTapped() - try await model.$searchResults.load() - try await model.searchTask?.value - 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, + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: 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" + 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" ), - 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, + [1]: SearchRemindersModel.Row( + isPastDue: 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" - ) - ] - """ + 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" + ) + ] + """ + } } } From 5df94bb77de4b997d1bba17e113fc7dccd35501a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 12:55:16 -0600 Subject: [PATCH 26/33] fixes --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 5 +- .../CloudKitTests/PreviewTests.swift | 70 +++++++++++++++++-- .../Internal/BaseCloudKitTests.swift | 8 +++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 9e5bb0d7..f7fab722 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -505,6 +505,7 @@ #if canImport(DeveloperToolsSupport) @Dependency(\.context) var context + @Dependency(\.continuousClock) var clock if context == .preview { previewTimerTask.withValue { $0?.cancel() @@ -512,7 +513,7 @@ await withErrorReporting { while true { guard let self else { break } - try await Task.sleep(for: .seconds(1)) + try await clock.sleep(for: .seconds(1)) try await self.syncChanges() } } @@ -603,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 { diff --git a/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift index a6350969..10dffa67 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift @@ -1,23 +1,23 @@ #if canImport(CloudKit) + import DependenciesTestSupport import InlineSnapshotTesting - import SQLiteData import SnapshotTestingCustomDump - import DependenciesTestSupport + 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, *) - @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)) + await testClock.advance(by: .seconds(1)) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -42,6 +42,68 @@ """ } } + + @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/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 4aec1bc8..d77f0f44 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -1,3 +1,5 @@ +#if canImport(CloudKit) +import Clocks import CloudKit import DependenciesTestSupport import OrderedCollections @@ -10,6 +12,7 @@ import os .snapshots(record: .missing), .dependencies { $0.currentTime.now = 0 + $0.continuousClock = TestClock() $0.dataManager = InMemoryDataManager() }, .attachMetadatabase(false) @@ -19,11 +22,15 @@ class BaseCloudKitTests: @unchecked Sendable { private let _syncEngine: any Sendable private let _container: any Sendable + @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 { @@ -240,3 +247,4 @@ private let currentUserRecordID = CKRecord.ID( // 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 From 090dd74c1b923134e351a7f619f3193885023dbe Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:13:01 -0600 Subject: [PATCH 27/33] fix --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f7fab722..f46cffed 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1970,7 +1970,7 @@ private func refreshLastKnownServerRecord(_ record: CKRecord) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await metadatabase.write { db in + try await userDatabase.write { db in let metadata = try SyncMetadata.find(record.recordID).fetchOne(db) func updateLastKnownServerRecord() throws { try SyncMetadata From 43727d5dc0914099344d6390e9ced5026b802f07 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:23:58 -0600 Subject: [PATCH 28/33] wip --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 11 ++++++++--- Sources/SQLiteDataTestSupport/AssertQuery.swift | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index ed08b520..c8694c61 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -195,7 +195,7 @@ """ ) } - try await metadatabase.write { db in + try await userDatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } .update { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f46cffed..f3869aae 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -20,7 +20,7 @@ public final class SyncEngine: Observable, Sendable { package let userDatabase: UserDatabase package let logger: Logger - package let metadatabase: any DatabaseWriter + package let metadatabase: any DatabaseReader package let tables: [any SynchronizableTable] package let privateTables: [any SynchronizableTable] let tablesByName: [String: any SynchronizableTable] @@ -738,8 +738,13 @@ try trigger.drop().execute(db) } } - try metadatabase.erase() - try migrate(metadatabase: metadatabase) + if let writableMetadatabase = metadatabase as? any DatabaseWriter { + func open(_ writableMetadatabase: some DatabaseWriter) throws { + try writableMetadatabase.erase() + try migrate(metadatabase: writableMetadatabase) + } + try open(writableMetadatabase) + } } /// Deletes synchronized data locally on device and restarts the sync engine. diff --git a/Sources/SQLiteDataTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift index 06684db1..999690a0 100644 --- a/Sources/SQLiteDataTestSupport/AssertQuery.swift +++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift @@ -51,7 +51,7 @@ public func assertQuery< >( includeSQL: Bool = false, _ query: S, - database: (any DatabaseWriter)? = nil, + database: (any DatabaseReader)? = nil, sql: (() -> String)? = nil, results: (() -> String)? = nil, fileID: StaticString = #fileID, @@ -80,7 +80,7 @@ public func assertQuery< let results = includeSQL ? results : sql do { @Dependency(\.defaultDatabase) var defaultDatabase - let rows = try (database ?? defaultDatabase).write { try query.fetchAll($0) } + let rows = try (database ?? defaultDatabase).read { try query.fetchAll($0) } var table = "" if rows.isEmpty { table = "(No results)" @@ -181,7 +181,7 @@ public func assertQuery< public func assertQuery( includeSQL: Bool = false, _ query: S, - database: (any DatabaseWriter)? = nil, + database: (any DatabaseReader)? = nil, sql: (() -> String)? = nil, results: (() -> String)? = nil, fileID: StaticString = #fileID, From 6d5e42bb6ebac1f68f47e3ececf745ec73f94fa9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:37:45 -0600 Subject: [PATCH 29/33] wip --- Sources/SQLiteData/CloudKit/SyncMetadata.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 2d64e7a4..40d89e85 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -10,7 +10,7 @@ /// /// See for more info. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Table("sqlitedata_icloud_metadata") + @Table("sqlitedata_icloud_metadata", schema: "sqlitedata_icloud") public struct SyncMetadata: Hashable, Identifiable, Sendable { /// A selection of columns representing a synchronized record's unique identifier and type. @Selection From 48b8f420abac2109d8a161d4a7480f9bc69fbb5c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:45:14 -0600 Subject: [PATCH 30/33] Revert "fix" This reverts commit 090dd74c1b923134e351a7f619f3193885023dbe. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f3869aae..c7e1c148 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1975,7 +1975,7 @@ private func refreshLastKnownServerRecord(_ record: CKRecord) async { await withErrorReporting(.sqliteDataCloudKitFailure) { - try await userDatabase.write { db in + try await metadatabase.write { db in let metadata = try SyncMetadata.find(record.recordID).fetchOne(db) func updateLastKnownServerRecord() throws { try SyncMetadata From 89941f76f3cec7215245f3e9f6f524c889f95e40 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:45:15 -0600 Subject: [PATCH 31/33] Revert "wip" This reverts commit 43727d5dc0914099344d6390e9ced5026b802f07. --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 11 +++-------- Sources/SQLiteDataTestSupport/AssertQuery.swift | 6 +++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index c8694c61..ed08b520 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -195,7 +195,7 @@ """ ) } - try await userDatabase.write { db in + try await metadatabase.write { db in try SyncMetadata .where { $0.recordName.eq(recordName) } .update { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index c7e1c148..f7fab722 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -20,7 +20,7 @@ public final class SyncEngine: Observable, Sendable { package let userDatabase: UserDatabase package let logger: Logger - package let metadatabase: any DatabaseReader + package let metadatabase: any DatabaseWriter package let tables: [any SynchronizableTable] package let privateTables: [any SynchronizableTable] let tablesByName: [String: any SynchronizableTable] @@ -738,13 +738,8 @@ try trigger.drop().execute(db) } } - if let writableMetadatabase = metadatabase as? any DatabaseWriter { - func open(_ writableMetadatabase: some DatabaseWriter) throws { - try writableMetadatabase.erase() - try migrate(metadatabase: writableMetadatabase) - } - try open(writableMetadatabase) - } + try metadatabase.erase() + try migrate(metadatabase: metadatabase) } /// Deletes synchronized data locally on device and restarts the sync engine. diff --git a/Sources/SQLiteDataTestSupport/AssertQuery.swift b/Sources/SQLiteDataTestSupport/AssertQuery.swift index 999690a0..06684db1 100644 --- a/Sources/SQLiteDataTestSupport/AssertQuery.swift +++ b/Sources/SQLiteDataTestSupport/AssertQuery.swift @@ -51,7 +51,7 @@ public func assertQuery< >( includeSQL: Bool = false, _ query: S, - database: (any DatabaseReader)? = nil, + database: (any DatabaseWriter)? = nil, sql: (() -> String)? = nil, results: (() -> String)? = nil, fileID: StaticString = #fileID, @@ -80,7 +80,7 @@ public func assertQuery< let results = includeSQL ? results : sql do { @Dependency(\.defaultDatabase) var defaultDatabase - let rows = try (database ?? defaultDatabase).read { try query.fetchAll($0) } + let rows = try (database ?? defaultDatabase).write { try query.fetchAll($0) } var table = "" if rows.isEmpty { table = "(No results)" @@ -181,7 +181,7 @@ public func assertQuery< public func assertQuery( includeSQL: Bool = false, _ query: S, - database: (any DatabaseReader)? = nil, + database: (any DatabaseWriter)? = nil, sql: (() -> String)? = nil, results: (() -> String)? = nil, fileID: StaticString = #fileID, From 84a476decd4b7d06fab46451f4d77b454c962377 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 13:45:16 -0600 Subject: [PATCH 32/33] Revert "wip" This reverts commit 6d5e42bb6ebac1f68f47e3ececf745ec73f94fa9. --- Sources/SQLiteData/CloudKit/SyncMetadata.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 40d89e85..2d64e7a4 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -10,7 +10,7 @@ /// /// See for more info. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Table("sqlitedata_icloud_metadata", schema: "sqlitedata_icloud") + @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Identifiable, Sendable { /// A selection of columns representing a synchronized record's unique identifier and type. @Selection From 87a787648855501dbf6584c6a20f494c12db8296 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 11 Jan 2026 20:55:39 -0600 Subject: [PATCH 33/33] modernize previews --- .../CloudKitDemo/CountersListFeature.swift | 5 ++- Examples/Reminders/RemindersLists.swift | 5 ++- Examples/Reminders/SearchReminders.swift | 5 ++- Examples/Reminders/TagsForm.swift | 5 ++- Examples/SyncUpTests/Internal.swift | 42 ++++++++++--------- Examples/SyncUpTests/SyncUpFormTests.swift | 5 +-- Examples/SyncUps/App.swift | 5 ++- Examples/SyncUps/SyncUpDetail.swift | 5 ++- Examples/SyncUps/SyncUpsList.swift | 5 ++- 9 files changed, 52 insertions(+), 30 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index ef764b0b..85edae77 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -127,11 +127,14 @@ struct CounterRow: View { } #Preview { + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } NavigationStack { CountersListView() } + .task { + try? database.seedSampleData() + } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 2be7ad13..4e000497 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -434,11 +434,14 @@ private struct ReminderGridCell: View { } #Preview { + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } NavigationStack { RemindersListsView(model: RemindersListsModel()) } + .task { + try? database.seedSampleData() + } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 8d81cc40..62698cc8 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -249,9 +249,9 @@ struct SearchRemindersView: View { #Preview { @Previewable @State var searchText = "take" + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } NavigationStack { @@ -264,6 +264,9 @@ struct SearchRemindersView: View { } .searchable(text: $searchText) } + .task { + try? database.seedSampleData() + } } nonisolated private func baseQuery( diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 61510300..452684fe 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -163,10 +163,13 @@ private struct TagView: View { #Preview { @Previewable @State var tags: [Tag] = [] + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } TagsView(selectedTags: $tags) + .task { + try? database.seedSampleData() + } } diff --git a/Examples/SyncUpTests/Internal.swift b/Examples/SyncUpTests/Internal.swift index 4db25057..1ca71503 100644 --- a/Examples/SyncUpTests/Internal.swift +++ b/Examples/SyncUpTests/Internal.swift @@ -3,27 +3,28 @@ 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: """ + 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 \ @@ -31,7 +32,8 @@ extension Database { 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 77b201e6..ad5fcd4a 100644 --- a/Examples/SyncUps/App.swift +++ b/Examples/SyncUps/App.swift @@ -77,9 +77,12 @@ struct AppView: View { } #Preview("Happy path") { + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } AppView(model: AppModel()) + .task { + try? database.seedSampleData() + } } diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 9b9050b2..303dc62c 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -271,9 +271,9 @@ struct MeetingView: View { } #Preview { + @Dependency(\.defaultDatabase) var database let syncUp = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() return try $0.defaultDatabase.read { db in try SyncUp.fetchOne(db)! } @@ -281,4 +281,7 @@ struct MeetingView: View { NavigationStack { SyncUpDetailView(model: SyncUpDetailModel(syncUp: syncUp)) } + .task { + try? database.seedSampleData() + } } diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index a19791d7..01f260fd 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -152,11 +152,14 @@ private struct SeedDatabaseTip: Tip { } #Preview { + @Dependency(\.defaultDatabase) var database let _ = try! prepareDependencies { try $0.bootstrapDatabase() - try $0.defaultDatabase.seedSampleData() } NavigationStack { SyncUpsList(model: SyncUpsListModel()) } + .task { + try? database.seedSampleData() + } }