From 2b794ef1394aac94add997dc222d78b70c4ffdce Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 21:04:14 -0500 Subject: [PATCH 01/11] More docs --- .../SQLiteData/CloudKit/CloudKitSharing.swift | 48 +++++++++++++++---- .../CloudKit/DefaultSyncEngine.swift | 37 ++++++++++++++ .../IdentifierStringConvertible.swift | 1 + .../CloudKit/SyncEngine.Event.swift | 2 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 38 ++++++++++++++- .../SQLiteData/CloudKit/SyncMetadata.swift | 7 ++- .../Articles/CloudKitSharing.md | 5 ++ .../SyncEngineLifecycleTests.swift | 9 ++++ 8 files changed, 133 insertions(+), 14 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index d1cc32f7..8a7c01a4 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -7,6 +7,9 @@ import UIKit #endif + /// A shared record that can be used to present a ``CloudSharingView`` + /// + /// See for more information., @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) public struct SharedRecord: Hashable, Identifiable, Sendable { let container: any CloudContainer @@ -43,7 +46,26 @@ "The record could not be shared." } } - + + /// Shares a record in CloudKit. + /// + /// This method will thrown an error if: + /// + /// * The table the `record` belongs to is not synchronized to CloudKit. + /// * The `record` has any foreign keys. Only root records are shareable in CloudKit. + /// * The table the `record` belongs to is a "private" table as determined by the + /// [`SyncEngine` initializer](). + /// * The `record` is being shared before it has been synchronized to CloudKit. + /// * Any of the CloudKit APIs invoked throw an error. + /// + /// The value returned from this method can be used to present a ``CloudSharingView`` which + /// allows the user to send a share URL to another user. + /// + /// - Parameters: + /// - record: The record to be shared on CloudKit. + /// - configure: A trailing closure that can customize the `CKShare` sent to CloudKit. See + /// [Apple's documentation](https://developer.apple.com/documentation/cloudkit/ckshare/systemfieldkey) + /// for more info on what can be configured. public func share( record: T, configure: @Sendable (CKShare) -> Void @@ -56,7 +78,8 @@ recordPrimaryKey: record.primaryKey.rawIdentifier, reason: .recordTableNotSynchronized, debugDescription: """ - Table is not shareable: table type not passed to 'tables' parameter of 'SyncEngine.init'. + Table is not shareable: table type not passed to 'tables' parameter of \ + 'SyncEngine.init'. """ ) } @@ -116,7 +139,6 @@ return try await container.database(for: rootRecord.recordID) .record(for: shareRecordID) as? CKShare } catch let error as CKError where error.code == .unknownItem { - reportIssue("This would have been a problem before") return nil } } @@ -133,8 +155,6 @@ ) configure(sharedRecord) - // TODO: We are getting an "client oplock error updating record" error in the logs when - // creating new shares / editing existing shares. _ = try await container.privateCloudDatabase.modifyRecords( saving: [sharedRecord, rootRecord], deleting: [] @@ -173,13 +193,21 @@ ) try result?.deleteResults.values.forEach { _ = try $0.get() } } - + + /// Accepts a shared record. + /// + /// This method should be invoked from various delegate methods on the scene delegate of the + /// app. See for more info. public func acceptShare(metadata: CKShare.Metadata) async throws { try await acceptShare(metadata: ShareMetadata(rawValue: metadata)) } } - #if canImport(UIKit) && !os(watchOS) +#if canImport(UIKit) && !os(watchOS) + /// A view that presents standard screens for adding and removing people from a CloudKit share \ + /// record. + /// + /// See for more info. @available(iOS 17, macOS 14, tvOS 17, *) public struct CloudSharingView: UIViewControllerRepresentable { let sharedRecord: SharedRecord @@ -204,8 +232,8 @@ self.syncEngine = syncEngine } - public func makeCoordinator() -> CloudSharingDelegate { - CloudSharingDelegate( + public func makeCoordinator() -> _CloudSharingDelegate { + _CloudSharingDelegate( share: sharedRecord.share, didFinish: didFinish, didStopSharing: didStopSharing, @@ -231,7 +259,7 @@ } @available(iOS 17, macOS 14, tvOS 17, *) - public final class CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { + public final class _CloudSharingDelegate: NSObject, UICloudSharingControllerDelegate { let share: CKShare let didFinish: (Result) -> Void let didStopSharing: () -> Void diff --git a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift index 7fe1a580..93b34516 100644 --- a/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/DefaultSyncEngine.swift @@ -5,6 +5,43 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension DependencyValues { + /// The default sync engine used by the application. + /// + /// Configure this as early as possible in your app's lifetime, like the app entry point in + /// SwiftUI, using `prepareDependencies`: + /// + /// ```swift + /// import SQLiteData + /// import SwiftUI + /// + /// ```swift + /// @main + /// struct MyApp: App { + /// init() { + /// prepareDependencies { + /// $0.defaultDatabase = try! appDatabase() + /// $0.defaultSyncEngine = SyncEngine( + /// for: $0.defaultDatabase, + /// tables: Item.self + /// ) + /// } + /// } + /// // ... + /// } + /// ``` + /// + /// > Note: You can only prepare the default sync engine a single time in the lifetime of + /// > your app. Attempting to do so more than once will produce a runtime warning. + /// + /// Once configured, access the default sync engine anywhere using `@Dependency`: + /// + /// ```swift + /// @Dependency(\.defaultSyncEngine) var syncEngine + /// + /// syncEngine.acceptShare(metadata: metadata) + /// ``` + /// + /// See for more info. public var defaultSyncEngine: SyncEngine { get { self[SyncEngine.self] } set { self[SyncEngine.self] = newValue } diff --git a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift index b7b2639b..54243d39 100644 --- a/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift +++ b/Sources/SQLiteData/CloudKit/IdentifierStringConvertible.swift @@ -1,5 +1,6 @@ import Foundation +/// A type that can be represented by a string identifier. public protocol IdentifierStringConvertible { init?(rawIdentifier: String) var rawIdentifier: String { get } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift b/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift index 74d8de9b..230aac02 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.Event.swift @@ -82,7 +82,7 @@ } } - public var description: String { + package var description: String { switch self { case .stateUpdate: "stateUpdate" case .accountChange: "accountChange" diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 471b04f7..3e04c2c5 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -9,6 +9,7 @@ import StructuredQueriesCore import SwiftData + /// An object that manages the synchronization of local and remote SQLite data. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public final class SyncEngine: Sendable { package let userDatabase: UserDatabase @@ -26,6 +27,25 @@ -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol) package let container: any CloudContainer let dataManager = Dependency(\.dataManager) + + /// The error message used when a write occurs to a record for which the current user + /// does not have permission. + /// + /// This error is thrown from any database write to a row for which the current user does + /// not have permissions to write, as determined by its `CKShare` (if applicable). To catch + /// this error try casting it to `DatabaseError` and checking its message: + /// + /// ```swift + /// do { + /// try await database.write { db in + /// Reminder.find(id) + /// .update { $0.title = "Personal" } + /// .execute(db) + /// } + /// } catch let error as DatabaseError where error.message == SyncEngine.writePermissionError { + /// // User does not have permission to write to this record. + /// } + /// ``` public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" public convenience init( @@ -252,11 +272,22 @@ } } } - + + /// Starts the sync engine if it is stopped. + /// + /// When a sync engine is started it will upload all data stored locally that has not yet + /// been synchronized to CloudKit, and will download all changes from CloudKit since the + /// last time it synchronized. + /// + /// > Note: By default, sync engines start syncing when initialized. public func start() async throws { try await start().value } + /// Stops the sync engine if it is running. + /// + /// 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() { guard isRunning else { return } syncEngines.withValue { @@ -264,6 +295,7 @@ } } + /// Determines if the sync engine is currently running or not. public var isRunning: Bool { syncEngines.withValue { $0.isRunning @@ -539,6 +571,10 @@ ) } + /// A query expression that can be used in SQL queries to determine if the ``SyncEngine`` + /// is currently writing changes to the database. + /// + /// See for more info. public static func isSynchronizingChanges() -> some QueryExpression { $syncEngineIsSynchronizingChanges() } diff --git a/Sources/SQLiteData/CloudKit/SyncMetadata.swift b/Sources/SQLiteData/CloudKit/SyncMetadata.swift index 2ecaf3e0..b9df93b8 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -8,7 +8,7 @@ /// application is the number of rows this one single table holds. However, this table is held /// in a database separate from your app's database. /// - /// +/// See for more info. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Table("sqlitedata_icloud_metadata") public struct SyncMetadata: Hashable, Sendable { @@ -131,7 +131,10 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { +extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { + /// A query for finding the metadata associated with a record. + /// + /// - Parameter primaryKey: The primary key of the record whose metadatab to look up. public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { SyncMetadata.where { #sql( diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md index 50bc341d..f599c68d 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKitSharing.md @@ -70,6 +70,11 @@ the view. That will cause a ``CloudSharingView`` sheet to be presented where the how they want to share the record. A record can be _unshared_ by presenting the same ``CloudSharingView`` to the user so that they can tap the "Stop sharing" button in the UI. +If you would like to provide a custom sharing experience outside of what `UICloudSharingController` +offers, you can find more info in [Apple's documentation]. + +[Apple's documentation]: https://developer.apple.com/documentation/cloudkit/shared-records + ## Accepting shared records Extra steps must be taken to allow a user to _accept_ a shared record. Once the user taps on the diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 9dcbf44b..31efb4e2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -93,6 +93,11 @@ } } + // * Create list + // * Stop sync engine + // * Delete list + // * Start sync engine + // => List is deleted from CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func writeStopDeleteStart() async throws { try await userDatabase.userWrite { db in @@ -129,6 +134,10 @@ } } + // * Stop sync engine + // * Edit list + // * Start sync engine + // => List is updated on CloudKit @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { try await userDatabase.userWrite { db in From e77c2aea8ff55a6cba1d8d05c3380a0731a57376 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 2 Sep 2025 21:09:58 -0500 Subject: [PATCH 02/11] wip --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 3e04c2c5..df3f62d9 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -47,7 +47,22 @@ /// } /// ``` public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" - + + /// Initialize a sync engine. + /// + /// - Parameters: + /// - database: The database to synchronize to CloudKit. + /// - tables: A list of tables that you want to synchronize _and_ that you want to be + /// shareable with other users on CloudKit. + /// - privateTables: A list of tables that you want to synchronize to CloudKit but that + /// you do not want to be shareable with other users. + /// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted + /// the container will be determined from the entitlements of your app. + /// - defaultZone: The zone for all records to be stored in. + /// - startImmediately: Determines if the sync engine starts right away or requires an + /// explicit call to ``stop()``. By default this argument is `true`. + /// - logger: The logger used to log events in the sync engine. By default a `.disabled` + /// logger is used, which means logs are not printed. public convenience init( for database: any DatabaseWriter, tables: repeat (each T1).Type, From 7586409a193f4f1f574b84d968fec98a805be76d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 3 Sep 2025 13:13:40 -0500 Subject: [PATCH 03/11] wip --- Examples/Examples.xcodeproj/project.pbxproj | 16 +- Examples/Reminders/RemindersDetail.swift | 1 + Package.resolved | 6 +- Package.swift | 3 +- README.md | 24 +- .../CloudKit/Internal/MockCloudDatabase.swift | 8 - .../CloudKit/Internal/Triggers.swift | 145 ++- Sources/SQLiteData/CloudKit/SyncEngine.swift | 84 +- .../Documentation.docc/Articles/CloudKit.md | 22 +- .../Articles/ComparisonWithSwiftData.md | 6 +- .../Articles/DynamicQueries.md | 3 + .../Documentation.docc/Articles/Fetching.md | 22 +- .../Articles/PreparingDatabase.md | 78 +- .../Documentation.docc/SQLiteData.md | 41 +- .../CloudKitTests/AccountLifecycleTests.swift | 1 + .../FetchRecordZoneChangesTests.swift | 233 +++++ .../MockCloudDatabaseTests.swift | 16 - .../SyncEngineLifecycleTests.swift | 928 +++++++++--------- .../CloudKitTests/TriggerTests.swift | 14 +- 19 files changed, 927 insertions(+), 724 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index e36be4b8..2db7d786 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -13,11 +13,11 @@ CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; + CA6A1D242E68A0A600604D6A /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = CA6A1D232E68A0A600604D6A /* SQLiteData */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; DCBE8A142D4842BF0071F499 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = DCBE8A132D4842BF0071F499 /* CasePaths */; }; DCD9AC8B2E02176700FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8A2E02176700FB20F8 /* SharingGRDB */; }; - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8C2E02177200FB20F8 /* SharingGRDB */; }; DCD9AC8F2E02177900FB20F8 /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = DCD9AC8E2E02177900FB20F8 /* SharingGRDB */; }; DCF267392D48437300B680BE /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = DCF267382D48437300B680BE /* SwiftUINavigation */; }; /* End PBXBuildFile section */ @@ -163,8 +163,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA6A1D242E68A0A600604D6A /* SQLiteData in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, - DCD9AC8D2E02177200FB20F8 /* SharingGRDB in Frameworks */, CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -337,7 +337,7 @@ packageProductDependencies = ( CA14DBC82DA884C400E36852 /* CasePaths */, CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */, + CA6A1D232E68A0A600604D6A /* SQLiteData */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -1076,6 +1076,11 @@ package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; productName = DependenciesTestSupport; }; + CA6A1D232E68A0A600604D6A /* SQLiteData */ = { + isa = XCSwiftPackageProductDependency; + package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; + productName = SQLiteData; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; @@ -1095,11 +1100,6 @@ isa = XCSwiftPackageProductDependency; productName = SharingGRDB; }; - DCD9AC8C2E02177200FB20F8 /* SharingGRDB */ = { - isa = XCSwiftPackageProductDependency; - package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; - productName = SharingGRDB; - }; DCD9AC8E2E02177900FB20F8 /* SharingGRDB */ = { isa = XCSwiftPackageProductDependency; package = DCD9AC892E02176700FB20F8 /* XCLocalSwiftPackageReference "../../sharing-grdb" */; diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 4e8a534f..e4327c1b 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,5 +1,6 @@ import CasePaths import CloudKit +import Sharing import SQLiteData import SwiftUI import SwiftUINavigation diff --git a/Package.resolved b/Package.resolved index 40558cc0..7ceaf661 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bf7f65a97bc0744011b5a84033dae2413dabad76028eccac59d6837fd798cf8a", + "originHash" : "91854284a607914a7c36076d7292303dfe28178e3af27cb244cf21846948e78a", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "14f79c6dad72e385c564c66dbe333522a11eaa48", - "version" : "0.16.0" + "branch" : "support-void-database-functions", + "revision" : "a6e0175d305547a29055085a89aab6c63c6fb3ce" } }, { diff --git a/Package.swift b/Package.swift index ce3e8b34..97723af0 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,8 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.16.0", + //from: "0.16.0", + branch: "support-void-database-functions", traits: [ .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] diff --git a/README.md b/README.md index ed1db5e5..5114d62d 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ var items: [Item] @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -178,6 +178,9 @@ var items +@FetchAll(Item.order(by: \.isInStock)) +var items + @FetchOne(Item.count()) var itemsCount = 0 @@ -198,6 +201,9 @@ var items: [Item] }) var items: [Item] +// No @Query equivalent of ordering +// by boolean column. + // No @Query equivalent of counting // entries in database without loading // all entries. @@ -303,14 +309,14 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SQLiteData (0.2.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +πŸ‘‰ SQLiteData (1.0.0) 0 0.172 8.511 + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 6232cf7f..0593f837 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -194,14 +194,6 @@ package final class MockCloudDatabase: CloudDatabase { // We are trying to save a record that does not have a change tag yet also already // exists in the DB. This means the user has created a new CKRecord from scratch, // giving it a new identity, rather than leveraging an existing CKRecord. - reportIssue( - """ - A new identity was created for an existing 'CKRecord' \ - ('\(existingRecord.recordID.recordName)'). Rather than creating \ - 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - ) saveResults[recordToSave.recordID] = .failure( CKError( .serverRejectedRequest, diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index c7fcf3ec..0cf5a6e6 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -51,6 +51,7 @@ ifNotExists: true, after: .update { _, new in checkWritePermissions(alias: new, parentForeignKey: parentForeignKey) + // TODO: change to update? SyncMetadata.upsert(new: new, parentForeignKey: parentForeignKey) } ) @@ -125,97 +126,74 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncMetadata { - static var callbackTriggers: [TemporaryTrigger] { + static func callbackTriggers(for syncEngine: SyncEngine) -> [TemporaryTrigger] { [ - afterInsertTrigger, - afterUpdateTrigger, - afterSoftDeleteTrigger, + afterInsertTrigger(for: syncEngine), + afterUpdateTrigger(for: syncEngine), + afterSoftDeleteTrigger(for: syncEngine), ] } private enum ParentSyncMetadata: AliasName {} - fileprivate static let afterInsertTrigger = createTemporaryTrigger( - "after_insert_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .insert { new in - Values(.didUpdate(new)) - } when: { _ in - !SyncEngine.isSynchronizingChanges() - } - ) - - fileprivate static let afterUpdateTrigger = createTemporaryTrigger( - "after_update_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update { _, new in - Values(.didUpdate(new)) - } when: { old, new in - old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() - } - ) - - fileprivate static let afterSoftDeleteTrigger = createTemporaryTrigger( - "after_delete_on_sqlitedata_icloud_metadata", - ifNotExists: true, - after: .update(of: \._isDeleted) { _, new in - Values( - .didDelete( - recordName: new.recordName, - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), - share: new.share + fileprivate static func afterInsertTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_insert_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .insert { new in + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName) + ) ) - ) - } when: { old, new in - !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() - } - ) - } - - extension QueryExpression where Self == SQLQueryExpression<()> { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didUpdate( - _ new: StructuredQueriesCore.TableAlias< - SyncMetadata, TemporaryTrigger.Operation._New - > - .TableColumns - ) -> Self { - .didUpdate( - recordName: new.recordName, - lastKnownServerRecord: new.lastKnownServerRecord - ?? rootServerRecord(recordName: new.recordName), - share: new.share + } when: { _ in + !SyncEngine.isSynchronizingChanges() + } ) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - private static func didUpdate( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression, - share: some QueryExpression - ) -> Self { - Self( - "\(raw: .sqliteDataCloudKitSchemaName)_didUpdate(\(recordName), \(lastKnownServerRecord), \(share))" + fileprivate static func afterUpdateTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_update_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update { _, new in + validate(recordName: new.recordName) + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName) + ) + ) + } when: { old, new in + old._isDeleted.eq(new._isDeleted) && !SyncEngine.isSynchronizingChanges() + } ) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - fileprivate static func didDelete( - recordName: some QueryExpression, - lastKnownServerRecord: some QueryExpression, - share: some QueryExpression - ) -> Self { - Self( - "\(raw: .sqliteDataCloudKitSchemaName)_didDelete(\(recordName), \(lastKnownServerRecord), \(share))" + fileprivate static func afterSoftDeleteTrigger(for syncEngine: SyncEngine) -> TemporaryTrigger { + createTemporaryTrigger( + "after_delete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update(of: \._isDeleted) { _, new in + Values( + syncEngine.$didDelete( + recordName: new.recordName, + record: new.lastKnownServerRecord + ?? rootServerRecord(recordName: new.recordName), + share: new.share + ) + ) + } when: { old, new in + !old._isDeleted && new._isDeleted && !SyncEngine.isSynchronizingChanges() + } ) } } - private func isUpdatingWithServerRecord() -> SQLQueryExpression { - #sql("\(raw: .sqliteDataCloudKitSchemaName)_isUpdatingWithServerRecord()") - } - private func parentFields( alias: StructuredQueriesCore.TableAlias.TableColumns, parentForeignKey: ForeignKey? @@ -225,6 +203,19 @@ ?? ("NULL", "NULL") } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private func validate( + recordName: some QueryExpression + ) -> some StructuredQueriesCore.Statement { + #sql( + """ + SELECT RAISE(ABORT, \(quote: SyncEngine.invalidRecordNameError, delimiter: .text)) + WHERE NOT \(recordName.isValidCloudKitRecordName) + """, + as: Never.self + ) + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func checkWritePermissions( alias: StructuredQueriesCore.TableAlias.TableColumns, @@ -297,4 +288,10 @@ ) } } + + extension QueryExpression { + fileprivate var isValidCloudKitRecordName: some QueryExpression { + substr(1, 1).neq("_") && octetLength().lte(255) && octetLength().eq(length()) + } + } #endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index df3f62d9..2a361d35 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -47,7 +47,8 @@ /// } /// ``` public static let writePermissionError = "co.pointfree.sqlitedata-icloud.write-permission-error" - + public static let invalidRecordNameError = "co.pointfree.sqlitedata-icloud.invalid-record-name-error" + /// Initialize a sync engine. /// /// - Parameters: @@ -270,11 +271,11 @@ } db.add(function: $datetime) db.add(function: $syncEngineIsSynchronizingChanges) - db.add(function: .didUpdate(syncEngine: self)) - db.add(function: .didDelete(syncEngine: self)) + db.add(function: $didUpdate) + db.add(function: $didDelete) db.add(function: $hasPermission) - for trigger in SyncMetadata.callbackTriggers { + for trigger in SyncMetadata.callbackTriggers(for: self) { try trigger.execute(db) } @@ -310,6 +311,7 @@ } } + // TODO: Should we make isRunning observable? /// Determines if the sync engine is currently running or not. public var isRunning: Bool { syncEngines.withValue { @@ -478,12 +480,12 @@ for table in tables { try table.dropTriggers(db: db) } - for trigger in SyncMetadata.callbackTriggers.reversed() { + for trigger in SyncMetadata.callbackTriggers(for: self).reversed() { try trigger.drop().execute(db) } db.remove(function: $hasPermission) - db.remove(function: .didDelete(syncEngine: self)) - db.remove(function: .didUpdate(syncEngine: self)) + db.remove(function: $didDelete) + db.remove(function: $didUpdate) db.remove(function: $syncEngineIsSynchronizingChanges) db.remove(function: $datetime) // TODO: Do an `.erase()` + re-migrate @@ -511,8 +513,12 @@ try setUpSyncEngine() } - func didUpdate(recordName: String, zoneID: CKRecordZone.ID?) { - let zoneID = zoneID ?? defaultZone.zoneID + @DatabaseFunction( + "sqlitedata_icloud_didUpdate", + as: ((String, CKRecord?.SystemFieldsRepresentation) -> Void).self + ) + func didUpdate(recordName: String, record: CKRecord?) { + let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID let change = CKSyncEngine.PendingRecordZoneChange.saveRecord( CKRecord.ID( recordName: recordName, @@ -538,8 +544,14 @@ syncEngine?.state.add(pendingRecordZoneChanges: [change]) } - func didDelete(recordName: String, zoneID: CKRecordZone.ID?, share: CKShare?) { - let zoneID = zoneID ?? defaultZone.zoneID + @DatabaseFunction( + "sqlitedata_icloud_didDelete", + as: ( + (String, CKRecord?.SystemFieldsRepresentation, CKShare?.SystemFieldsRepresentation) -> Void + ).self + ) + func didDelete(recordName: String, record: CKRecord?, share: CKShare?) { + let zoneID = record?.recordID.zoneID ?? defaultZone.zoneID var changes: [CKSyncEngine.PendingRecordZoneChange] = [ .deleteRecord( CKRecord.ID( @@ -1625,56 +1637,6 @@ } } - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension GRDB.DatabaseFunction { - fileprivate static func didUpdate(syncEngine: SyncEngine) -> Self { - Self("didUpdate") { recordName, zoneID, _ in - syncEngine.didUpdate( - recordName: recordName, - zoneID: zoneID - ) - } - } - - fileprivate static func didDelete(syncEngine: SyncEngine) -> Self { - return Self("didDelete") { recordName, zoneID, share in - syncEngine - .didDelete( - recordName: recordName, - zoneID: zoneID, - share: share - ) - } - } - - private convenience init( - _ name: String, - function: @escaping @Sendable (String, CKRecordZone.ID?, CKShare?) -> Void - ) { - self.init(.sqliteDataCloudKitSchemaName + "_" + name, argumentCount: 3) { arguments in - guard - let recordName = String.fromDatabaseValue(arguments[0]) - else { - return nil - } - let zoneID = try Data.fromDatabaseValue(arguments[1]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKRecord(coder: coder)?.recordID.zoneID - } - - let share = try Data.fromDatabaseValue(arguments[2]).flatMap { - let coder = try NSKeyedUnarchiver(forReadingFrom: $0) - coder.requiresSecureCoding = true - return CKShare(coder: coder) - } - - function(recordName, zoneID, share) - return nil - } - } - } - extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" package static let sqliteDataCloudKitFailure = "SQLiteData CloudKit Failure" diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 38983b4e..6f946699 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -71,8 +71,7 @@ Before constructing a ``SyncEngine`` you must have already created and migrated SQLite database as detailed in . Immediately after that is done in the `prepareDependencies` of the entry point of your app you will override the ``Dependencies/DependencyValues/defaultSyncEngine`` dependency with a sync engine that specifies -the CloudKit container to use, the database to synchronize, as well as the tables you want to -synchronize: +the database to synchronize, as well as the tables you want to synchronize: ```swift @main @@ -96,7 +95,8 @@ The `SyncEngine` has more options you may be interested in configuring. > 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. +> you can have the option of having some local tables that are not synchronized to CloudKit, such +> full-text search indices, cached data, etc. 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 @@ -143,7 +143,7 @@ a unique ID by simply adding 1 to the largest ID in the table. However, that doe with distributed schemas. That would make it possible for two devices to create a record with `id: 1`, and when those records synchronize there would be an irreconcilable conflict. -For this reason, primary keys in SQLite tables should be globally unique, such as a UUID. The +For this reason, primary keys in SQLite tables should be _globally_ unique, such as a UUID. The easiest way to do this is to store your table's ID in a `TEXT` column, adding a default with a freshly generated UUID, and further adding a `ON CONFLICT REPLACE` constraint: @@ -163,7 +163,7 @@ the primary key from the default value specified. This kind of pattern is common ```swift try database.write { db in try Reminder.upsert { - // Do not provide 'id', let database initialize it for you. + // ℹ️ Omitting 'id' allows the database to initialize it for you. Reminder.Draft(title: "Get milk") } .execute(db) @@ -183,8 +183,8 @@ CREATE TABLE "reminders" ( ) ``` -> Tip: If you want the database to generate random UUID's in a deterministic fashion for tests -> you can register a custom database function to be used. +Registering custom database functions for ID generation also makes it possible to generate +deterministic IDs for tests, making it easier to test your queries. #### Primary keys on every table @@ -201,7 +201,7 @@ CREATE TABLE "reminderTags" ( "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE -) +) STRICT ``` Note that the `id` column might not be needed for your application's logic, but it is necessary to @@ -223,6 +223,12 @@ For this reason uniqueness constraints are not allowed in schemas, and this will when a ``SyncEngine`` is first created. If a uniqueness constraint is detected an error will be thrown. +Sometimes it is possible to make the column that you want to be unique + +> Important: discuss limitations of custom PK + +[CKRecord.ID]: https://developer.apple.com/documentation/cloudkit/ckrecord/id + #### Foreign key relationships > TL;DR: Foreign key constraints can be enabled and you can use `ON DELETE` actions to diff --git a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md index 16e5b97d..8a9bb691 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/ComparisonWithSwiftData.md @@ -39,7 +39,7 @@ to SwiftData's `@Model` macro: // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -610,7 +610,7 @@ Lightweight migrations in SwiftData work for simple situations, such as adding a // SQLiteData @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true } @@ -652,7 +652,7 @@ adding a `description` field to the `Item` type: ```swift @Table struct Item { - let id: Int + let id: UUID var title = "" var description = "" var isInStock = true diff --git a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md index a875f2b5..9d4a7dca 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/DynamicQueries.md @@ -89,3 +89,6 @@ struct ContentView: View { > initial `@FetchAll`'s value, taken from the parent. To manage the state of this dynamic query > locally to this view, we use `@State @FetchAll`, instead, and to access the underlying > `FetchAll` value you can use `wrappedValue`. +> +> This only happens when using `@FetchAll`/`@FetchOne`/`@Fetch` directly in a view, and does not +> affect using these tools elsewhere in your application. diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index acb51ded..f0c8b278 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -26,7 +26,7 @@ your table: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -97,7 +97,7 @@ exactly one list: ```swift @Table struct Reminder { - let id: Int + let id: UUID var title = "" var dueAt: Date? var isCompleted = false @@ -105,7 +105,7 @@ struct Reminder { } @Table struct RemindersList: Identifiable { - let id: Int + let id: UUID var title = "" } ``` @@ -151,9 +151,9 @@ you must construct a dedicated `FetchDescriptor` value and set its `propertiesTo ### @FetchOne The [`@FetchOne`]() property wrapper works similarly to `@FetchAll`, but fetches -only a single record from the database and you must provide a default for when no record is found. -This tool can be handy for computing aggregate data, such as the number of reminders in the -database: +only a single record from the database and you must provide a default for when no record is found or +use an optional value. This tool can be handy for computing aggregate data, such as the number of +reminders in the database: ```swift @FetchOne(Reminder.count()) @@ -179,9 +179,9 @@ var completedRemindersCount = 0 It is also possible to execute multiple database queries to fetch data for your features. This can be useful for performing several queries in a single database transaction: -Each instance of `@FetchAll` in a feature executes their queries in a separate transaction. So, if -we wanted to query for all completed reminders, along with a total count of reminders (completed and -uncompleted), we could do so like this: +Each instance of `@FetchAll` and `@FetchOne` executes their queries in a separate transaction and +manage separate observations of the database. So, if we wanted to query for all completed reminders, +along with a total count of reminders (completed and uncompleted), we could do so like this: ```swift @FetchOne(Reminder.count()) @@ -191,13 +191,13 @@ var remindersCount = 0 var completedReminders ``` -…this is technically 2 queries run in 2 separate database transactions. +…this is technically 2 separate database transactions with 2 separate observations. Often this can be just fine, but if you have multiple queries that tend to change at the same time (_e.g._, when reminders are created or deleted, `remindersCount` and `completedReminders` will change at the same time), then you can bundle these two queries into a single transaction. -To do this, one simply defines a conformance to our ``FetchKeyRequest`` protocol, and in that +To do this, one defines a conformance to our ``FetchKeyRequest`` protocol, and in that conformance one can use the builder tools to query the database: ```swift diff --git a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md index 1239280b..07c87d7e 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/PreparingDatabase.md @@ -14,6 +14,7 @@ and Xcode previews. * [Step 3: Create database connection](#Step-3-Create-database-connection) * [Step 4: Migrate database](#Step-4-Migrate-database) * [Step 5: Set database connection in entry point](#Step-5-Set-database-connection-in-entry-point) +* [(Optional) Step 6: Set up CloudKit SyncEngine](#Optional-Step-6-Set-up-CloudKit-SyncEngine) ### Step 1: App database connection @@ -45,10 +46,7 @@ data: + configuration.foreignKeysEnabled = true } ``` - -> Important: If you are synchronizing your database to CloudKit, then you must not enable -> foreign keys. See for more information. - + This will prevent you from deleting rows that leave other rows with invalid associations. For example, if a "reminders" table had an association to a "remindersLists" table, you would not be allowed to delete a list row unless there were no reminders associated with it, or if you had @@ -156,16 +154,16 @@ context or if we're in a preview or test. - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - let database = try DatabasePool(path: path, configuration: configuration) -+ @Dependency(\.context) var context + let database: any DatabaseWriter -+ if context == .live { ++ switch context { ++ case .live: + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) -+ } else if context == .test { ++ case .test: + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) -+ } else { ++ case .preview: + database = try DatabaseQueue(configuration: configuration) + } return database @@ -196,14 +194,15 @@ database connection: } #endif let database: any DatabaseWriter - if context == .live { + switch context { + case .live: let path = URL.documentsDirectory.appending(component: "db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { + case .test: let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() database = try DatabasePool(path: path, configuration: configuration) - } else { + case .preview: database = try DatabaseQueue(configuration: configuration) } + var migrator = DatabaseMigrator() @@ -265,31 +264,32 @@ import OSLog import SQLiteData func appDatabase() throws -> any DatabaseWriter { - @Dependency(\.context) var context - var configuration = Configuration() - configuration.foreignKeysEnabled = true - #if DEBUG - configuration.prepareDatabase { db in - db.trace(options: .profile) { - if context == .preview { - print("\($0.expandedDescription)") - } else { - logger.debug("\($0.expandedDescription)") - } - } - } - #endif - let database: any DatabaseWriter - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } else if context == .test { - let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() - database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) - } + @Dependency(\.context) var context + var configuration = Configuration() + configuration.foreignKeysEnabled = true + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } + } + } + #endif + let database: any DatabaseWriter + switch context { + case .live: + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) + case .test: + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) + case .preview: + database = try DatabaseQueue(configuration: configuration) + } var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true @@ -369,3 +369,9 @@ func feature() { // ... } ``` + +### (Optional) Step 6: Set up CloudKit SyncEngine + +If you plan on synchronizing your local database to CloudKit so that your user's data is available +on all of their devices, there is an additional step you must take. See + for more information. diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 4bf75257..4fd1ed78 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -17,7 +17,7 @@ of targets. @Table struct Item { - let id: Int + let id: UUID var title = "" var isInStock = true var notes = "" @@ -117,7 +117,12 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr @FetchAll(Item.where(\.isInStock)) var items - + + + + @FetchAll(Item.order(by: \.isInStock)) + var items + @FetchOne(Item.count()) var itemsCount = 0 ``` @@ -130,8 +135,13 @@ This `defaultDatabase` connection is used implicitly by SQLiteData's property wr @Query(sort: [SortDescriptor(\.title)]) var items: [Item] - // No @Query equivalent of filtering - // by 'isInStock: Bool' + @Query(filter: #Predicate { + $0.isInStock + }) + var items: [Item] + + // No @Query equivalent of ordering + // by boolean column. // No @Query equivalent of counting // entries in database without loading @@ -150,8 +160,8 @@ a model context, via a property wrapper: @Dependency(\.defaultDatabase) var database try database.write { db in - try Item.insert(Item(/* ... */)) - .execute(db) + try Item.insert { Item(/* ... */) } + .execute(db) } ``` } @@ -189,12 +199,9 @@ struct MyApp: App { } ``` -> [!NOTE] > For more information on synchronizing the database to CloudKit and sharing records with iCloud > users, see . -[CloudKit Synchronization] - This is all you need to know to get started with SQLiteData, but there's much more to learn. Read the [articles](#Essentials) below to learn how to best utilize this library. @@ -210,14 +217,14 @@ See the following benchmarks against taste of how it compares: ``` -Orders.fetchAll setup rampup duration - SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 - Lighter (1.4.10) 0 0.164 8.059 - SQLiteData (1.0.0) 0 0.172 8.511 - GRDB (7.4.1, manual decoding) 0 0.376 18.819 - SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 - SQLite.swift (0.15.3, Codable) 0 0.863 43.261 - GRDB (7.4.1, Codable) 0.002 1.07 53.326 +Orders.fetchAll setup rampup duration + SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 + Lighter (1.4.10) 0 0.164 8.059 +πŸ‘‰ SQLiteData (1.0.0) 0 0.172 8.511 + GRDB (7.4.1, manual decoding) 0 0.376 18.819 + SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 + SQLite.swift (0.15.3, Codable) 0 0.863 43.261 + GRDB (7.4.1, Codable) 0.002 1.07 53.326 ``` ## SQLite knowledge required diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index ad47b160..73c2f64a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -122,6 +122,7 @@ } } + // TODO: look into if there are more tests to write here @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func doNotUploadExistingDataToCloudKitWhenSignedOut() { } diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index a39bc452..c0847578 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -542,6 +542,239 @@ """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagLocallyThenCreateSameTagRemotely() async throws { + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Tag(title: "tag") β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncMetadata( β”‚ + β”‚ recordPrimaryKey: "tag", β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ recordName: "tag:tags", β”‚ + β”‚ parentRecordPrimaryKey: nil, β”‚ + β”‚ parentRecordType: nil, β”‚ + β”‚ parentRecordName: nil, β”‚ + β”‚ lastKnownServerRecord: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil β”‚ + β”‚ ), β”‚ + β”‚ _lastKnownServerRecordAllFields: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil, β”‚ + β”‚ title: "tag" β”‚ + β”‚ ), β”‚ + β”‚ share: nil, β”‚ + β”‚ _isDeleted: false, β”‚ + β”‚ isShared: false, β”‚ + β”‚ userModificationDate: Date(1970-01-01T00:00:00.000Z) β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func createTagRemotelyThenCreateSameTagLocally() async throws { + let tagRecord = CKRecord( + recordType: Tag.tableName, + recordID: Tag.recordID(for: "tag") + ) + tagRecord.encryptedValues["title"] = "tag" + let modifications = try syncEngine.modifyRecords(scope: .private, saving: [tagRecord]) + + try await userDatabase.userWrite { db in + try db.seed { + Tag(title: "tag") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + await modifications.notify() + + assertQuery(Tag.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Tag(title: "tag") β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncMetadata( β”‚ + β”‚ recordPrimaryKey: "tag", β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ recordName: "tag:tags", β”‚ + β”‚ parentRecordPrimaryKey: nil, β”‚ + β”‚ parentRecordType: nil, β”‚ + β”‚ parentRecordName: nil, β”‚ + β”‚ lastKnownServerRecord: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil β”‚ + β”‚ ), β”‚ + β”‚ _lastKnownServerRecordAllFields: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil, β”‚ + β”‚ title: "tag" β”‚ + β”‚ ), β”‚ + β”‚ share: nil, β”‚ + β”‚ _isDeleted: false, β”‚ + β”‚ isShared: false, β”‚ + β”‚ userModificationDate: Date(1970-01-01T00:00:00.000Z) β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(tag:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "tag" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await userDatabase.userWrite { db in + try Tag.find("tag").update { $0.title = "weekend" }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Tag.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Tag(title: "weekend") β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery(SyncMetadata.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncMetadata( β”‚ + β”‚ recordPrimaryKey: "weekend", β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ recordName: "weekend:tags", β”‚ + β”‚ parentRecordPrimaryKey: nil, β”‚ + β”‚ parentRecordType: nil, β”‚ + β”‚ parentRecordName: nil, β”‚ + β”‚ lastKnownServerRecord: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil β”‚ + β”‚ ), β”‚ + β”‚ _lastKnownServerRecordAllFields: CKRecord( β”‚ + β”‚ recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), β”‚ + β”‚ recordType: "tags", β”‚ + β”‚ parent: nil, β”‚ + β”‚ share: nil, β”‚ + β”‚ title: "weekend" β”‚ + β”‚ ), β”‚ + β”‚ share: nil, β”‚ + β”‚ _isDeleted: false, β”‚ + β”‚ isShared: false, β”‚ + β”‚ userModificationDate: Date(1970-01-01T00:00:00.000Z) β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(weekend:tags/zone/__defaultOwner__), + recordType: "tags", + parent: nil, + share: nil, + title: "weekend" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func invalidRecordName() async throws { + let error = await #expect(throws: DatabaseError.self) { + try await self.userDatabase.userWrite { db in + try Tag.insert { Tag(title: "_tag") }.execute(db) + } + } + #expect(error?.message == SyncEngine.invalidRecordNameError) + } } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index d0e71c1f..fd94e0f8 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -385,22 +385,6 @@ #expect(error == CKError(.notAuthenticated)) } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func incorrectlyCreatingNewRecordIdentity() async throws { - let record1 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - _ = try syncEngine.modifyRecords(scope: .private, saving: [record1]) - let record2 = CKRecord(recordType: "A", recordID: CKRecord.ID.init(recordName: "1")) - try withKnownIssue { - _ = try syncEngine.modifyRecords(scope: .private, saving: [record2]) - } matching: { issue in - issue.description == """ - Issue recorded: A new identity was created for an existing 'CKRecord' ('1'). Rather than \ - creating 'CKRecord' from scratch for an existing record, use the database to fetch the \ - current record. - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func saveShareWithoutRootRecord() async throws { let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "1")) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 31efb4e2..b417448b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -1,464 +1,464 @@ -#if canImport(CloudKit) - import CloudKit - import DependenciesTestSupport - import InlineSnapshotTesting - import OrderedCollections - import SQLiteData - import SnapshotTesting - import SnapshotTestingCustomDump - import Testing - import os - - extension BaseCloudKitTests { - @Suite - struct SyncEngineLifecycleTests { - @MainActor - @Suite - final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked - Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func stopAndReStart() async throws { - syncEngine.stop() - - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - - try await Task.sleep(for: .seconds(0.5)) - - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) - - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - } - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - // * Create list - // * Stop sync engine - // * Delete list - // * Start sync engine - // => List is deleted from CloudKit - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeStopDeleteStart() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - syncEngine.stop() - - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - - try await Task.sleep(for: .seconds(0.5)) - - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - // * Stop sync engine - // * Edit list - // * Start sync engine - // => List is updated on CloudKit - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - syncEngine.stop() - - try await withDependencies { - $0.datetime.now.addTimeInterval(1) - } operation: { - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title += "!" }.execute(db) - } - try await Task.sleep(for: .seconds(0.5)) - - try await userDatabase.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) - try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") - } - - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - 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.read { db in - try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) - } - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() - - syncEngine.stop() - - try await withDependencies { - $0.datetime.now.addTimeInterval(60) - } operation: { - try await userDatabase.userWrite { db in - try db.seed { - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - } - - try await Task.sleep(for: .seconds(0.5)) - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/external.zone/external.owner), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - isCompleted: 0, - title: "Personal" - ) - ] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() - async throws - { - let externalZoneID = CKRecordZone.ID( - zoneName: "external.zone", - ownerName: "external.owner" - ) - let externalZone = CKRecordZone(zoneID: externalZoneID) - - let remindersListRecord = CKRecord( - recordType: RemindersList.tableName, - recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) - ) - remindersListRecord.setValue(1, forKey: "id", at: now) - remindersListRecord.setValue(false, forKey: "isCompleted", at: now) - remindersListRecord.setValue("Personal", forKey: "title", at: now) - - try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() - try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() - - syncEngine.stop() - - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .shared) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") - try await userDatabase.userWrite { db in - try db.seed { remindersList } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), - recordType: "cloudkit.share", - parent: nil, - share: nil - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - syncEngine.stop() - - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - } - - try await Task.sleep(for: .seconds(0.5)) - try await syncEngine.start() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - } - - @MainActor - final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked - Sendable - { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - try await super.init(startImmediately: false) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func writeAndThenStart() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, title: "Get milk", remindersListID: 1) - } - } - - try await userDatabase.userRead { db in - let remindersListMetadata = try #require( - try RemindersList.metadata(for: 1).fetchOne(db)) - #expect(remindersListMetadata.lastKnownServerRecord == nil) - - let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) - #expect(reminderMetadata.lastKnownServerRecord == nil) - #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) - } - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await syncEngine.start() - await signIn() - try await syncEngine.processPendingDatabaseChanges(scope: .private) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - id: 1, - isCompleted: 0, - remindersListID: 1, - title: "Get milk" - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Personal" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - } - } - } -#endif +//#if canImport(CloudKit) +// import CloudKit +// import DependenciesTestSupport +// import InlineSnapshotTesting +// import OrderedCollections +// import SQLiteData +// import SnapshotTesting +// import SnapshotTestingCustomDump +// import Testing +// import os +// +// extension BaseCloudKitTests { +// @Suite +// struct SyncEngineLifecycleTests { +// @MainActor +// @Suite +// final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked +// Sendable +// { +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func stopAndReStart() async throws { +// syncEngine.stop() +// +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// Reminder(id: 1, title: "Get milk", remindersListID: 1) +// } +// } +// +// try await Task.sleep(for: .seconds(0.5)) +// +// try await userDatabase.userRead { db in +// let remindersListMetadata = try #require( +// try RemindersList.metadata(for: 1).fetchOne(db)) +// #expect(remindersListMetadata.lastKnownServerRecord == nil) +// +// let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) +// #expect(reminderMetadata.lastKnownServerRecord == nil) +// #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) +// } +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), +// share: nil, +// id: 1, +// isCompleted: 0, +// remindersListID: 1, +// title: "Get milk" +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: 1, +// title: "Personal" +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } +// +// // * Create list +// // * Stop sync engine +// // * Delete list +// // * Start sync engine +// // => List is deleted from CloudKit +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func writeStopDeleteStart() async throws { +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// } +// } +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// syncEngine.stop() +// +// try await userDatabase.userWrite { db in +// try RemindersList.find(1).delete().execute(db) +// } +// +// try await Task.sleep(for: .seconds(0.5)) +// +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } +// +// // * Stop sync engine +// // * Edit list +// // * Start sync engine +// // => List is updated on CloudKit +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// } +// } +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// syncEngine.stop() +// +// try await withDependencies { +// $0.datetime.now.addTimeInterval(1) +// } operation: { +// try await userDatabase.userWrite { db in +// try RemindersList.find(1).update { $0.title += "!" }.execute(db) +// } +// try await Task.sleep(for: .seconds(0.5)) +// +// try await userDatabase.read { db in +// try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) +// try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") +// } +// +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// 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.read { db in +// try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) +// } +// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { +// let externalZoneID = CKRecordZone.ID( +// zoneName: "external.zone", +// ownerName: "external.owner" +// ) +// let externalZone = CKRecordZone(zoneID: externalZoneID) +// +// let remindersListRecord = CKRecord( +// recordType: RemindersList.tableName, +// recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) +// ) +// remindersListRecord.setValue(1, forKey: "id", at: now) +// remindersListRecord.setValue(false, forKey: "isCompleted", at: now) +// remindersListRecord.setValue("Personal", forKey: "title", at: now) +// +// try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() +// try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() +// +// syncEngine.stop() +// +// try await withDependencies { +// $0.datetime.now.addTimeInterval(60) +// } operation: { +// try await userDatabase.userWrite { db in +// try db.seed { +// Reminder(id: 1, title: "Get milk", remindersListID: 1) +// } +// } +// } +// +// try await Task.sleep(for: .seconds(0.5)) +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .shared) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/external.zone/external.owner), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), +// share: nil, +// id: 1, +// isCompleted: 0, +// remindersListID: 1, +// title: "Get milk" +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: 1, +// isCompleted: 0, +// title: "Personal" +// ) +// ] +// ) +// ) +// """ +// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() +// async throws +// { +// let externalZoneID = CKRecordZone.ID( +// zoneName: "external.zone", +// ownerName: "external.owner" +// ) +// let externalZone = CKRecordZone(zoneID: externalZoneID) +// +// let remindersListRecord = CKRecord( +// recordType: RemindersList.tableName, +// recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) +// ) +// remindersListRecord.setValue(1, forKey: "id", at: now) +// remindersListRecord.setValue(false, forKey: "isCompleted", at: now) +// remindersListRecord.setValue("Personal", forKey: "title", at: now) +// +// try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() +// try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() +// +// syncEngine.stop() +// +// try await userDatabase.userWrite { db in +// try RemindersList.find(1).delete().execute(db) +// } +// +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .shared) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { +// let remindersList = RemindersList(id: 1, title: "Personal") +// try await userDatabase.userWrite { db in +// try db.seed { remindersList } +// } +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), +// recordType: "cloudkit.share", +// parent: nil, +// share: nil +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// +// syncEngine.stop() +// +// try await userDatabase.userWrite { db in +// try RemindersList.find(1).delete().execute(db) +// } +// +// try await Task.sleep(for: .seconds(0.5)) +// try await syncEngine.start() +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } +// } +// +// @MainActor +// final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked +// Sendable +// { +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// init() async throws { +// try await super.init(startImmediately: false) +// } +// +// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +// @Test func writeAndThenStart() async throws { +// try await userDatabase.userWrite { db in +// try db.seed { +// RemindersList(id: 1, title: "Personal") +// Reminder(id: 1, title: "Get milk", remindersListID: 1) +// } +// } +// +// try await userDatabase.userRead { db in +// let remindersListMetadata = try #require( +// try RemindersList.metadata(for: 1).fetchOne(db)) +// #expect(remindersListMetadata.lastKnownServerRecord == nil) +// +// let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) +// #expect(reminderMetadata.lastKnownServerRecord == nil) +// #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) +// } +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// +// try await syncEngine.start() +// await signIn() +// try await syncEngine.processPendingDatabaseChanges(scope: .private) +// try await syncEngine.processPendingRecordZoneChanges(scope: .private) +// +// assertInlineSnapshot(of: container, as: .customDump) { +// """ +// MockCloudContainer( +// privateCloudDatabase: MockCloudDatabase( +// databaseScope: .private, +// storage: [ +// [0]: CKRecord( +// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), +// recordType: "reminders", +// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), +// share: nil, +// id: 1, +// isCompleted: 0, +// remindersListID: 1, +// title: "Get milk" +// ), +// [1]: CKRecord( +// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), +// recordType: "remindersLists", +// parent: nil, +// share: nil, +// id: 1, +// title: "Personal" +// ) +// ] +// ), +// sharedCloudDatabase: MockCloudDatabase( +// databaseScope: .shared, +// storage: [] +// ) +// ) +// """ +// } +// } +// } +// } +// } +//#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index c0e8ad90..5f21b04a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -22,7 +22,7 @@ CREATE TRIGGER "after_delete_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN ((NOT ("old"."_isDeleted") AND "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN - SELECT sqlitedata_icloud_didDelete("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT "sqlitedata_icloud_didDelete"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -42,7 +42,9 @@ CREATE TRIGGER "after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -55,14 +57,16 @@ SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); + ))); END """, [2]: """ CREATE TRIGGER "after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("old"."_isDeleted" = "new"."_isDeleted") AND NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN - SELECT sqlitedata_icloud_didUpdate("new"."recordName", coalesce("new"."lastKnownServerRecord", ( + SELECT RAISE(ABORT, 'co.pointfree.sqlitedata-icloud.invalid-record-name-error') + WHERE NOT (((substr("new"."recordName", 1, 1) <> '_') AND (octet_length("new"."recordName") <= 255)) AND (octet_length("new"."recordName") = length("new"."recordName"))); + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", coalesce("new"."lastKnownServerRecord", ( WITH "ancestorMetadatas" AS ( SELECT "sqlitedata_icloud_metadata"."recordName" AS "recordName", "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."lastKnownServerRecord" AS "lastKnownServerRecord" FROM "sqlitedata_icloud_metadata" @@ -75,7 +79,7 @@ SELECT "ancestorMetadatas"."lastKnownServerRecord" FROM "ancestorMetadatas" WHERE ("ancestorMetadatas"."parentRecordName" IS NULL) - )), "new"."share"); + ))); END """, [3]: """ From d0ec94bdfb3a1fcaa29b8acde00889c6f1765d8c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 3 Sep 2025 13:25:32 -0500 Subject: [PATCH 04/11] wip --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 7ceaf661..095a3f91 100644 --- a/Package.resolved +++ b/Package.resolved @@ -124,7 +124,7 @@ "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { "branch" : "support-void-database-functions", - "revision" : "a6e0175d305547a29055085a89aab6c63c6fb3ce" + "revision" : "0eada18fb533047e7336cedd7d99da8dd9e0a8d8" } }, { From 42191498be46d9b37371889b736eaaa746d313e6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 3 Sep 2025 13:29:07 -0500 Subject: [PATCH 05/11] wip --- .../SyncEngineLifecycleTests.swift | 928 +++++++++--------- 1 file changed, 464 insertions(+), 464 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index b417448b..31efb4e2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -1,464 +1,464 @@ -//#if canImport(CloudKit) -// import CloudKit -// import DependenciesTestSupport -// import InlineSnapshotTesting -// import OrderedCollections -// import SQLiteData -// import SnapshotTesting -// import SnapshotTestingCustomDump -// import Testing -// import os -// -// extension BaseCloudKitTests { -// @Suite -// struct SyncEngineLifecycleTests { -// @MainActor -// @Suite -// final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked -// Sendable -// { -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func stopAndReStart() async throws { -// syncEngine.stop() -// -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// Reminder(id: 1, title: "Get milk", remindersListID: 1) -// } -// } -// -// try await Task.sleep(for: .seconds(0.5)) -// -// try await userDatabase.userRead { db in -// let remindersListMetadata = try #require( -// try RemindersList.metadata(for: 1).fetchOne(db)) -// #expect(remindersListMetadata.lastKnownServerRecord == nil) -// -// let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) -// #expect(reminderMetadata.lastKnownServerRecord == nil) -// #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) -// } -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), -// share: nil, -// id: 1, -// isCompleted: 0, -// remindersListID: 1, -// title: "Get milk" -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: 1, -// title: "Personal" -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } -// -// // * Create list -// // * Stop sync engine -// // * Delete list -// // * Start sync engine -// // => List is deleted from CloudKit -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func writeStopDeleteStart() async throws { -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// } -// } -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// syncEngine.stop() -// -// try await userDatabase.userWrite { db in -// try RemindersList.find(1).delete().execute(db) -// } -// -// try await Task.sleep(for: .seconds(0.5)) -// -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } -// -// // * Stop sync engine -// // * Edit list -// // * Start sync engine -// // => List is updated on CloudKit -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// } -// } -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// syncEngine.stop() -// -// try await withDependencies { -// $0.datetime.now.addTimeInterval(1) -// } operation: { -// try await userDatabase.userWrite { db in -// try RemindersList.find(1).update { $0.title += "!" }.execute(db) -// } -// try await Task.sleep(for: .seconds(0.5)) -// -// try await userDatabase.read { db in -// try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) -// try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") -// } -// -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// 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.read { db in -// try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) -// } -// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { -// let externalZoneID = CKRecordZone.ID( -// zoneName: "external.zone", -// ownerName: "external.owner" -// ) -// let externalZone = CKRecordZone(zoneID: externalZoneID) -// -// let remindersListRecord = CKRecord( -// recordType: RemindersList.tableName, -// recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) -// ) -// remindersListRecord.setValue(1, forKey: "id", at: now) -// remindersListRecord.setValue(false, forKey: "isCompleted", at: now) -// remindersListRecord.setValue("Personal", forKey: "title", at: now) -// -// try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() -// try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() -// -// syncEngine.stop() -// -// try await withDependencies { -// $0.datetime.now.addTimeInterval(60) -// } operation: { -// try await userDatabase.userWrite { db in -// try db.seed { -// Reminder(id: 1, title: "Get milk", remindersListID: 1) -// } -// } -// } -// -// try await Task.sleep(for: .seconds(0.5)) -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .shared) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/external.zone/external.owner), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), -// share: nil, -// id: 1, -// isCompleted: 0, -// remindersListID: 1, -// title: "Get milk" -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: 1, -// isCompleted: 0, -// title: "Personal" -// ) -// ] -// ) -// ) -// """ -// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() -// async throws -// { -// let externalZoneID = CKRecordZone.ID( -// zoneName: "external.zone", -// ownerName: "external.owner" -// ) -// let externalZone = CKRecordZone(zoneID: externalZoneID) -// -// let remindersListRecord = CKRecord( -// recordType: RemindersList.tableName, -// recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) -// ) -// remindersListRecord.setValue(1, forKey: "id", at: now) -// remindersListRecord.setValue(false, forKey: "isCompleted", at: now) -// remindersListRecord.setValue("Personal", forKey: "title", at: now) -// -// try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() -// try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() -// -// syncEngine.stop() -// -// try await userDatabase.userWrite { db in -// try RemindersList.find(1).delete().execute(db) -// } -// -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .shared) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { -// let remindersList = RemindersList(id: 1, title: "Personal") -// try await userDatabase.userWrite { db in -// try db.seed { remindersList } -// } -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), -// recordType: "cloudkit.share", -// parent: nil, -// share: nil -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// -// syncEngine.stop() -// -// try await userDatabase.userWrite { db in -// try RemindersList.find(1).delete().execute(db) -// } -// -// try await Task.sleep(for: .seconds(0.5)) -// try await syncEngine.start() -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } -// } -// -// @MainActor -// final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked -// Sendable -// { -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// init() async throws { -// try await super.init(startImmediately: false) -// } -// -// @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) -// @Test func writeAndThenStart() async throws { -// try await userDatabase.userWrite { db in -// try db.seed { -// RemindersList(id: 1, title: "Personal") -// Reminder(id: 1, title: "Get milk", remindersListID: 1) -// } -// } -// -// try await userDatabase.userRead { db in -// let remindersListMetadata = try #require( -// try RemindersList.metadata(for: 1).fetchOne(db)) -// #expect(remindersListMetadata.lastKnownServerRecord == nil) -// -// let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) -// #expect(reminderMetadata.lastKnownServerRecord == nil) -// #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) -// } -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// -// try await syncEngine.start() -// await signIn() -// try await syncEngine.processPendingDatabaseChanges(scope: .private) -// try await syncEngine.processPendingRecordZoneChanges(scope: .private) -// -// assertInlineSnapshot(of: container, as: .customDump) { -// """ -// MockCloudContainer( -// privateCloudDatabase: MockCloudDatabase( -// databaseScope: .private, -// storage: [ -// [0]: CKRecord( -// recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), -// recordType: "reminders", -// parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), -// share: nil, -// id: 1, -// isCompleted: 0, -// remindersListID: 1, -// title: "Get milk" -// ), -// [1]: CKRecord( -// recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), -// recordType: "remindersLists", -// parent: nil, -// share: nil, -// id: 1, -// title: "Personal" -// ) -// ] -// ), -// sharedCloudDatabase: MockCloudDatabase( -// databaseScope: .shared, -// storage: [] -// ) -// ) -// """ -// } -// } -// } -// } -// } -//#endif +#if canImport(CloudKit) + import CloudKit + import DependenciesTestSupport + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTesting + import SnapshotTestingCustomDump + import Testing + import os + + extension BaseCloudKitTests { + @Suite + struct SyncEngineLifecycleTests { + @MainActor + @Suite + final class SyncEngineLifecycleTests_ImmediatelyStarted: BaseCloudKitTests, @unchecked + Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func stopAndReStart() async throws { + syncEngine.stop() + + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await Task.sleep(for: .seconds(0.5)) + + try await userDatabase.userRead { db in + let remindersListMetadata = try #require( + try RemindersList.metadata(for: 1).fetchOne(db)) + #expect(remindersListMetadata.lastKnownServerRecord == nil) + + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Create list + // * Stop sync engine + // * Delete list + // * Start sync engine + // => List is deleted from CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeStopDeleteStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await Task.sleep(for: .seconds(0.5)) + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // * Stop sync engine + // * Edit list + // * Start sync engine + // => List is updated on CloudKit + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addRemindersList_StopSyncEngine_EditTitle_StartSyncEngine() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(1) + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title += "!" }.execute(db) + } + try await Task.sleep(for: .seconds(0.5)) + + try await userDatabase.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 1) + try #expect(RemindersList.find(1).fetchOne(db)?.title == "Personal!") + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + 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.read { db in + try #expect(PendingRecordZoneChange.all.fetchCount(db) == 0) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func getSharedRecord_StopSyncEngine_WriteToSharedRecord_StartSyncing() async throws { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await withDependencies { + $0.datetime.now.addTimeInterval(60) + } operation: { + try await userDatabase.userWrite { db in + try db.seed { + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + } + + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/external.zone/external.owner), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/external.zone/external.owner), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + isCompleted: 0, + title: "Personal" + ) + ] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func externalSharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() + async throws + { + let externalZoneID = CKRecordZone.ID( + zoneName: "external.zone", + ownerName: "external.owner" + ) + let externalZone = CKRecordZone(zoneID: externalZoneID) + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1, zoneID: externalZoneID) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue(false, forKey: "isCompleted", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + try await syncEngine.modifyRecordZones(scope: .shared, saving: [externalZone]).notify() + try await syncEngine.modifyRecords(scope: .shared, saving: [remindersListRecord]).notify() + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .shared) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sharedRecord_StopSyncEngine_DeleteSharedRecord_StartSyncEngine() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { remindersList } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let _ = try await syncEngine.share(record: remindersList, configure: { _ in }) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__), + recordType: "cloudkit.share", + parent: nil, + share: nil + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: CKReference(recordID: CKRecord.ID(share-1:remindersLists/zone/__defaultOwner__)) + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await Task.sleep(for: .seconds(0.5)) + try await syncEngine.start() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + + @MainActor + final class SyncEngineLifecycleTests_ImmediatelyStopped: BaseCloudKitTests, @unchecked + Sendable + { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + try await super.init(startImmediately: false) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func writeAndThenStart() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + + try await userDatabase.userRead { db in + let remindersListMetadata = try #require( + try RemindersList.metadata(for: 1).fetchOne(db)) + #expect(remindersListMetadata.lastKnownServerRecord == nil) + + let reminderMetadata = try #require(try Reminder.metadata(for: 1).fetchOne(db)) + #expect(reminderMetadata.lastKnownServerRecord == nil) + #expect(reminderMetadata.parentRecordName == RemindersList.recordName(for: 1)) + } + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + try await syncEngine.start() + await signIn() + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: 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 2f09ace2ea3b1e031a9d71a6c4a91643208322a6 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 11:38:59 -0700 Subject: [PATCH 06/11] Format SQLiteData entry in README.md table --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5114d62d..0f0906eb 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,9 @@ taste of how it compares: Orders.fetchAll setup rampup duration SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 Lighter (1.4.10) 0 0.164 8.059 -πŸ‘‰ SQLiteData (1.0.0) 0 0.172 8.511 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SQLiteData (1.0.0) 0 0.172 8.511 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ GRDB (7.4.1, manual decoding) 0 0.376 18.819 SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 SQLite.swift (0.15.3, Codable) 0 0.863 43.261 From a19ab9e725bba35fef5c2ed8ffa074ed2d792b8a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 3 Sep 2025 13:41:02 -0500 Subject: [PATCH 07/11] wip --- Package.resolved | 6 +++--- Package.swift | 3 +-- Package@swift-6.0.swift | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 095a3f91..d2942708 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "91854284a607914a7c36076d7292303dfe28178e3af27cb244cf21846948e78a", + "originHash" : "3b49a4e324dfd736adfe38cb30f7c3a771fb77d8faee549e703df3e8b4f7f8fd", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "branch" : "support-void-database-functions", - "revision" : "0eada18fb533047e7336cedd7d99da8dd9e0a8d8" + "revision" : "adad5c6c5abe0c62f93c573de5be071043f621a8", + "version" : "0.17.0" } }, { diff --git a/Package.swift b/Package.swift index 97723af0..9c740c67 100644 --- a/Package.swift +++ b/Package.swift @@ -36,8 +36,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - //from: "0.16.0", - branch: "support-void-database-functions", + from: "0.17.0", traits: [ .trait(name: "StructuredQueriesTagged", condition: .when(traits: ["SQLiteDataTagged"])) ] diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 499b0acb..b1241a7f 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -27,7 +27,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.16.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.17.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ From e7f7199319ed286aebe0d03374c8f586afab2e6f Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 11:42:29 -0700 Subject: [PATCH 08/11] Update Sources/SQLiteData/CloudKit/CloudKitSharing.swift --- Sources/SQLiteData/CloudKit/CloudKitSharing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 8a7c01a4..54bbed07 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -7,7 +7,7 @@ import UIKit #endif - /// A shared record that can be used to present a ``CloudSharingView`` + /// A shared record that can be used to present a ``CloudSharingView``. /// /// See for more information., @available(iOS 15, tvOS 15, macOS 12, watchOS 8, *) From 26f5966f084bede4d1bff53614bdf85065da0cd8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 11:45:38 -0700 Subject: [PATCH 09/11] Update Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md --- Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md index 6f946699..f0c1e3e1 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/CloudKit.md @@ -95,7 +95,7 @@ The `SyncEngine` has more options you may be interested in configuring. > 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, such +> you can have the option of having some local tables that are not synchronized to CloudKit, such as > full-text search indices, cached data, etc. Once this work is done the app should work exactly as it did before, but now any changes made From 592ecb1bdc0ab4fe6ce447b7fb47882ede82f078 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 11:46:43 -0700 Subject: [PATCH 10/11] Update Sources/SQLiteData/CloudKit/SyncMetadata.swift --- 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 b9df93b8..2c676081 100644 --- a/Sources/SQLiteData/CloudKit/SyncMetadata.swift +++ b/Sources/SQLiteData/CloudKit/SyncMetadata.swift @@ -134,7 +134,7 @@ extension PrimaryKeyedTable where PrimaryKey: IdentifierStringConvertible { /// A query for finding the metadata associated with a record. /// - /// - Parameter primaryKey: The primary key of the record whose metadatab to look up. + /// - Parameter primaryKey: The primary key of the record whose metadata to look up. public static func metadata(for primaryKey: PrimaryKey.QueryOutput) -> Where { SyncMetadata.where { #sql( From 4a289f76763198ee93356bb101cd271882b8e5f9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 3 Sep 2025 11:48:20 -0700 Subject: [PATCH 11/11] Format SQLiteData performance table for clarity --- Sources/SQLiteData/Documentation.docc/SQLiteData.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 4fd1ed78..54e7464f 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -220,7 +220,9 @@ taste of how it compares: Orders.fetchAll setup rampup duration SQLite (generated by Enlighter 1.4.10) 0 0.144 7.183 Lighter (1.4.10) 0 0.164 8.059 -πŸ‘‰ SQLiteData (1.0.0) 0 0.172 8.511 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SQLiteData (1.0.0) 0 0.172 8.511 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ GRDB (7.4.1, manual decoding) 0 0.376 18.819 SQLite.swift (0.15.3, manual decoding) 0 0.564 27.994 SQLite.swift (0.15.3, Codable) 0 0.863 43.261