diff --git a/Examples/CloudKitDemo/CloudKitDemoApp.swift b/Examples/CloudKitDemo/CloudKitDemoApp.swift index fdcf1dc6..e96f652b 100644 --- a/Examples/CloudKitDemo/CloudKitDemoApp.swift +++ b/Examples/CloudKitDemo/CloudKitDemoApp.swift @@ -30,11 +30,8 @@ struct CloudKitDemoApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer(identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitDemo"), - database: $0.defaultDatabase, - tables: [ - Counter.self - ] + for: $0.defaultDatabase, + tables: Counter.self ) } return true diff --git a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift index 1f90aeb0..44c9512f 100644 --- a/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift +++ b/Examples/CloudKitPlayground/CloudKitPlaygroundApp.swift @@ -11,11 +11,8 @@ struct CloudKitPlaygroundApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = try! SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ), - database: $0.defaultDatabase, - tables: [ModelA.self, ModelB.self, ModelC.self] + for: $0.defaultDatabase, + tables: ModelA.self, ModelB.self, ModelC.self ) } } diff --git a/Examples/CloudKitPlayground/Schema.swift b/Examples/CloudKitPlayground/Schema.swift index ab63de71..9b38d3f6 100644 --- a/Examples/CloudKitPlayground/Schema.swift +++ b/Examples/CloudKitPlayground/Schema.swift @@ -22,9 +22,7 @@ func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase( - containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.CloudKitPlayground" - ) + try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0acc6d3c..46621b31 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" : "69853b99c9eb6c69968432d61f94ae83a716bf02937dd3eaea7567cdf3966f5d", + "originHash" : "cfa986227a2051ca83eae9c181301c75e9fcd30d8f199a7ee1c7b269b548e192", "pins" : [ { "identity" : "combine-schedulers", @@ -73,24 +73,6 @@ "version" : "1.9.2" } }, - { - "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", @@ -154,6 +136,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index a1c89133..115e51f3 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,6 +1,7 @@ import CloudKit import SharingGRDB import SwiftUI +import UIKit @main struct RemindersApp: App { @@ -13,17 +14,12 @@ struct RemindersApp: App { try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - RemindersListAsset.self, - Reminder.self, - Tag.self, - ReminderTag.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, + RemindersListAsset.self, + Reminder.self, + Tag.self, + ReminderTag.self ) } } @@ -40,9 +36,6 @@ struct RemindersApp: App { } } - -import UIKit - class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject { func application( _ application: UIApplication, diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 1266c9de..9b6be2df 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -105,9 +105,7 @@ func appDatabase() throws -> any DatabaseWriter { let database: any DatabaseWriter var configuration = Configuration() configuration.prepareDatabase { db in - try db.attachMetadatabase( - containerIdentifier: "iCloud.co.pointfree.SQLiteData.demos.field-timestamps-2.Reminders" - ) + try db.attachMetadatabase() #if DEBUG db.trace(options: .profile) { if context == .live { diff --git a/README.md b/README.md index db3d6062..812dfe1d 100644 --- a/README.md +++ b/README.md @@ -260,13 +260,8 @@ struct MyApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = SyncEngine( - container: CKContainer( - identifier: "iCloud.co.mycompany.MyApp" - ), - database: $0.defaultDatabase, - tables: [ - Item.self, - ] + for: $0.defaultDatabase, + tables: Item.self ) } } diff --git a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift index f641e3fc..7fe1a580 100644 --- a/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/DefaultSyncEngine.swift @@ -1,20 +1,20 @@ #if canImport(CloudKit) -import CloudKit -import Dependencies -import GRDB + import CloudKit + import Dependencies + import GRDB -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension DependencyValues { - public var defaultSyncEngine: SyncEngine { - get { self[SyncEngine.self] } - set { self[SyncEngine.self] = newValue } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension DependencyValues { + public var defaultSyncEngine: SyncEngine { + get { self[SyncEngine.self] } + set { self[SyncEngine.self] = newValue } + } } -} -@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -extension SyncEngine: TestDependencyKey { - public static var testValue: SyncEngine { - try! SyncEngine(container: .default(), database: DatabaseQueue(), tables: []) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine: TestDependencyKey { + public static var testValue: SyncEngine { + try! SyncEngine(for: DatabaseQueue()) + } } -} #endif diff --git a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift index 6dd0951f..790b4f98 100644 --- a/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift +++ b/Sources/SharingGRDBCore/CloudKit/SyncEngine.swift @@ -4,6 +4,8 @@ import CustomDump import OrderedCollections import OSLog + import StructuredQueriesCore + import SwiftData @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Sendable { @@ -24,14 +26,41 @@ let dataManager = Dependency(\.dataManager) - public convenience init( - container: CKContainer, + public convenience init( + for database: any DatabaseWriter, + tables: repeat (each T1).Type, + privateTables: repeat (each T2).Type, + containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), - database: any DatabaseWriter, - logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit"), - tables: [any PrimaryKeyedTable.Type], - privateTables: [any PrimaryKeyedTable.Type] = [] - ) throws { + logger: Logger = Logger(subsystem: "SQLiteData", category: "CloudKit") + ) throws + where + repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible + { + let containerIdentifier = containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + guard let containerIdentifier else { + throw SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + + let container = CKContainer(identifier: containerIdentifier) + var allTables: [any PrimaryKeyedTable.Type] = [] + var allPrivateTables: [any PrimaryKeyedTable.Type] = [] + for table in repeat each tables { + allTables.append(table) + } + for privateTable in repeat each privateTables { + allPrivateTables.append(privateTable) + } + let userDatabase = UserDatabase(database: database) try self.init( container: container, @@ -66,8 +95,8 @@ }, userDatabase: userDatabase, logger: logger, - tables: tables, - privateTables: privateTables + tables: allTables, + privateTables: allPrivateTables ) _ = try setUpSyncEngine( userDatabase: userDatabase, @@ -1483,7 +1512,7 @@ /// func appDatabase() -> any DatabaseWriter { /// var configuration = Configuration() /// configuration.prepareDatabase = { db in - /// db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + /// db.attachMetadatabase() /// … /// } /// } @@ -1493,7 +1522,20 @@ /// /// - Parameter containerIdentifier: The identifier of the CloudKit container used to synchronize /// data. - public func attachMetadatabase(containerIdentifier: String) throws { + public func attachMetadatabase(containerIdentifier: String? = nil) throws { + let containerIdentifier = containerIdentifier + ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + + guard let containerIdentifier else { + throw SyncEngine.SchemaError( + reason: .noCloudKitContainer, + debugDescription: """ + No default CloudKit container found. Please add a container identifier to your app's \ + entitlements. + """ + ) + } + let databasePath = try SQLQueryExpression( """ SELECT "file" FROM pragma_database_list() @@ -1539,6 +1581,7 @@ case invalidForeignKeyAction(ForeignKey) case invalidTableName(String) case metadatabaseMismatch(attachedPath: String, syncEngineConfiguredPath: String) + case noCloudKitContainer case nonNullColumnsWithoutDefault(tableName: String, columnNames: [String]) case triggersWithoutSynchronizationCheck([String]) case unknown diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md index aaeb2f4d..abaa1d20 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKit.md @@ -11,43 +11,43 @@ to make, and so an abundance of care must be taken to make sure all devices rema and capable of communicating with each other. Please read the documentation closely and thoroughly to make sure you understand how to best prepare your app for cloud synchronization. -- [Setting up your project](#Setting-up-your-project) -- [Setting up a SyncEngine](#Setting-up-a-SyncEngine) -- [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) - - [Primary keys](#Primary-keys) - - [Primary keys on every table](#Primary-keys-on-every-table) - - [Foreign key relationships](#Foreign-key-relationships) -- [Record conflicts](#Record-conflicts) -- [Backwards compatible migrations](#Backwards-compatible-migrations) - - [Adding tables](#Adding-tables) - - [Adding columns](#Adding-columns) - - [Disallowed migrations](#Disallowed-migrations) -- [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) -- [Assets](#Assets) -- [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) -- [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) -- [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) -- [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) - - [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) - - [Add primary key to all tables](#Add-primary-key-to-all-tables) -- [Migrating from Swift Data to SharingGRDB](#Migrating-from-Swift-Data-to-SharingGRDB) -- [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) -- [Tips and tricks](#Tips-and-tricks) - - [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) -- [Topics](#Topics) - - [Go deeper](#Go-deeper) + * [Setting up your project](#Setting-up-your-project) + * [Setting up a SyncEngine](#Setting-up-a-SyncEngine) + * [Designing your schema with synchronization in mind](#Designing-your-schema-with-synchronization-in-mind) + * [Primary keys](#Primary-keys) + * [Primary keys on every table](#Primary-keys-on-every-table) + * [Foreign key relationships](#Foreign-key-relationships) + * [Record conflicts](#Record-conflicts) + * [Backwards compatible migrations](#Backwards-compatible-migrations) + * [Adding tables](#Adding-tables) + * [Adding columns](#Adding-columns) + * [Disallowed migrations](#Disallowed-migrations) + * [Sharing records with other iCloud users](#Sharing-records-with-other-iCloud-users) + * [Assets](#Assets) + * [Accessing CloudKit metadata](#Accessing-CloudKit-metadata) + * [How SharingGRDB handles distributed schema scenarios](#How-SharingGRDB-handles-distributed-schema-scenarios) + * [Unit testing and Xcode previews](#Unit-testing-and-Xcode-previews) + * [Preparing an existing schema for synchronization](#Preparing-an-existing-schema-for-synchronization) + * [Convert Int primary keys to UUID](#Convert-Int-primary-keys-to-UUID) + * [Add primary key to all tables](#Add-primary-key-to-all-tables) + * [Migrating from Swift Data to SharingGRDB](#Migrating-from-Swift-Data-to-SharingGRDB) + * [Separating schema migrations from data migrations](#Separating-schema-migrations-from-data-migrations) + * [Tips and tricks](#Tips-and-tricks) + * [Updating triggers to be compatible with synchronization](#Updating-triggers-to-be-compatible-with-synchronization) + * [Topics](#Topics) + * [Go deeper](#Go-deeper) ## Setting up your project The steps to set up your SharingGRDB project for CloudKit synchronization are the [same for setting up][setup-cloudkit-apple] any other kind of project for CloudKit: -* Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. -* Follow the [Configuring background execution modes] guide for adding the Background Modes -capability to your project. -* If you want to enable sharing of records with other iCloud users, be sure to add a -`CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented -in [Apple's documentation for sharing]. + * Follow the [Configuring iCloud services] guide for enabling iCloud entitlements in your project. + * Follow the [Configuring background execution modes] guide for adding the Background Modes + capability to your project. + * If you want to enable sharing of records with other iCloud users, be sure to add a + `CKSharingSupported` key to your Info.plist with a value of `true`. This is subtly documented + in [Apple's documentation for sharing]. With those steps completed, you are ready to configure a ``SyncEngine`` that will facilitate synchronizing your database to and from CloudKit. @@ -78,32 +78,22 @@ struct MyApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self ) } } - … + // ... } ``` The `SyncEngine` - [initializer]() +[initializer]() has more options you may be interested in configuring. -> Important: A few important things to note about this: -> -> * The CloudKit container identifier must be explicitly provided and unfortunately cannot be -> extracted from Entitlements.plist automatically. That privilege is only afforded to SwiftData. -> * You must explicitly provide all tables that you want to synchronize. We do this so that you can -> have the option of having some local tables that are not synchronized to CloudKit. +> Important: You must explicitly provide all tables that you want to synchronize. We do this so that +> you can have the option of having some local tables that are not synchronized to CloudKit. Once this work is done the app should work exactly as it did before, but now any changes made to the database will be synchronized to CloudKit. You will still interact with your local SQLite @@ -118,7 +108,7 @@ you can use the `prepareDatabase` method on `Configuration` to attach the metada func appDatabase() -> any DatabaseWriter { var configuration = Configuration() configuration.prepareDatabase = { db in - db.attachMetadatabase(containerIdentifier: "iCloud.my.company.MyApp") + db.attachMetadatabase() … } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md index 4072ba3a..f4121aa0 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/CloudKitSharing.md @@ -391,17 +391,9 @@ struct MyApp: App { try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ], - privateTables: [ - RemindersListPrivate.self - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self, + privateTables: RemindersListPrivate.self ) } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md index 6934e915..7420f6b9 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -797,14 +797,8 @@ inspect the Entitlements.plist in order to automatically extract that informatio try! prepareDependencies { $0.defaultDatabase = try appDatabase() $0.defaultSyncEngine = try SyncEngine( - container: CKContainer( - identifier: "iCloud.co.pointfree.sharing-grdb.Reminders" - ), - database: $0.defaultDatabase, - tables: [ - RemindersList.self, - Reminder.self, - ] + for: $0.defaultDatabase, + tables: RemindersList.self, Reminder.self ) } } diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index ef52d9c5..f8f968c5 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -184,13 +184,8 @@ struct MyApp: App { prepareDependencies { $0.defaultDatabase = try! appDatabase() $0.defaultSyncEngine = SyncEngine( - container: CKContainer( - identifier: "iCloud.co.mycompany.MyApp" - ), - database: $0.defaultDatabase, - tables: [ - /* ... */ - ] + for: $0.defaultDatabase, + tables: /* ... */ ) } }