diff --git a/Package.resolved b/Package.resolved index a192e0c6..afcfbdf6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "2b95f254bb904411c5a0aba0e0402c157014542e9bd40eb5848c42c8598b80a9", + "originHash" : "a37b2b07639954a69f6637cd0967cb45f09a53621a14b735275a899800e8d6fa", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", - "version" : "1.1.0" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "36e30a6f1ef10e4194f6af0cff90888526f0c115", - "version" : "7.10.0" + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "2a2a938798236b8fa0bc57c453ee9de9f9ec3ab0", - "version" : "1.4.1" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "c79f72b3e67a1eb64f66f76704c22ed6a5c1ed84", - "version" : "1.11.0" + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "e977f65879f82b375a044c8837597f690c067da6", - "version" : "1.4.6" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "bf8d8c27f0f0c6d5e77bff0db76ab68f2050d15d", - "version" : "1.18.9" + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", - "version" : "1.9.0" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], diff --git a/Package.swift b/Package.swift index e1f278e8..fa03221b 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,11 @@ let package = Package( .trait( name: "SQLiteDataTagged", description: "Introduce SQLiteData conformances to the swift-tagged package." - ) + ), + .trait( + name: "SQLiteDataSwiftLog", + description: "Use swift-log instead of OSLog for logging." + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), @@ -44,6 +48,7 @@ let package = Package( ), .package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.10.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.6.0") ], targets: [ .target( @@ -62,6 +67,11 @@ let package = Package( package: "swift-tagged", condition: .when(traits: ["SQLiteDataTagged"]) ), + .product( + name: "Logging", + package: "swift-log", + condition: .when(traits: ["SQLiteDataSwiftLog"]) + ) ] ), .target( diff --git a/Sources/SQLiteData/CloudKit/Internal/Logging.swift b/Sources/SQLiteData/CloudKit/Internal/Logging.swift index b7d4b214..9701475f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Logging.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Logging.swift @@ -1,10 +1,47 @@ -#if DEBUG && canImport(CloudKit) +#if canImport(CloudKit) + +#if SQLiteDataSwiftLog + @_exported import struct Logging.Logger + import protocol Logging.LogHandler + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + public typealias Logger = Logging.Logger + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine.Logger { + public static var `default`: SyncEngine.Logger { + .init(label: "SQLiteData") + } + public static var disabled: SyncEngine.Logger { + .init(label: "SQLiteData") { _ in DisabledLogHandler() } + } + } +#else + @_exported import struct os.Logger + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine { + public typealias Logger = os.Logger + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension SyncEngine.Logger { + public static var `default`: SyncEngine.Logger { + .init(subsystem: "SQLiteData", category: "CloudKit") + } + public static var disabled: SyncEngine.Logger { + .init(.disabled) + } + } +#endif + +#if DEBUG import CloudKit import TabularData import os @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension Logger { + extension SyncEngine.Logger { func log(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) { let prefix = "SQLiteData (\(syncEngine.database.databaseScope.label).db)" var actions: [String] = [] @@ -302,3 +339,25 @@ } } #endif + +#if SQLiteDataSwiftLog + struct DisabledLogHandler: Logging.LogHandler { + var logLevel: Logging.Logger.Level = .info + var metadata: Logging.Logger.Metadata = [:] + subscript(metadataKey key: String) -> Logging.Logger.Metadata.Value? { + get { self.metadata[key] } + set { self.metadata[key] = newValue } + } + func log( + level: Logging.Logger.Level, + message: Logging.Logger.Message, + metadata: Logging.Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) {} + } +#endif + +#endif diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..fc1b9148 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -3,7 +3,6 @@ import ConcurrencyExtras import Dependencies import OrderedCollections - import OSLog import Observation import StructuredQueriesCore import SwiftData @@ -93,8 +92,7 @@ defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), startImmediately: Bool? = nil, delegate: (any SyncEngineDelegate)? = nil, - logger: Logger = isTesting - ? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit") + logger: Logger = isTesting ? Logger.disabled : Logger.default ) throws where repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index d54dc32b..e828f0e4 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -1,126 +1,83 @@ -#if canImport(CloudKit) - import Clocks - import CloudKit - import DependenciesTestSupport - import OrderedCollections - import SQLiteData - import SnapshotTesting - import Testing - import os +import CloudKit +import DependenciesTestSupport +import OrderedCollections +import SQLiteData +import SnapshotTesting +import Testing +import os - @Suite( - .snapshots(record: .missing), - .dependencies { - $0.currentTime.now = 0 - $0.continuousClock = TestClock() - $0.dataManager = InMemoryDataManager() - }, - .attachMetadatabase(false) - ) - class BaseCloudKitTests: @unchecked Sendable { - let userDatabase: UserDatabase - private let _syncEngine: any Sendable - private let _container: any Sendable +@Suite( + .snapshots(record: .missing), + .dependencies { + $0.currentTime.now = 0 + $0.dataManager = InMemoryDataManager() + }, + .attachMetadatabase(false) +) +class BaseCloudKitTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable - @Dependency(\.continuousClock) var clock - @Dependency(\.currentTime.now) var now - @Dependency(\.dataManager) var dataManager - var inMemoryDataManager: InMemoryDataManager { - dataManager as! InMemoryDataManager - } - var testClock: TestClock { - clock as! TestClock - } + @Dependency(\.currentTime.now) var now + @Dependency(\.dataManager) var dataManager + var inMemoryDataManager: InMemoryDataManager { + dataManager as! InMemoryDataManager + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var container: MockCloudContainer { - _container as! MockCloudContainer - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - var syncEngine: SyncEngine { - _syncEngine as! SyncEngine - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { - let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" - self.userDatabase = UserDatabase( - database: try SQLiteDataTests.database( - containerIdentifier: testContainerIdentifier, - attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase - ) - ) - try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) - let privateDatabase = MockCloudDatabase(databaseScope: .private) - let sharedDatabase = MockCloudDatabase(databaseScope: .shared) - let container = MockCloudContainer( - accountStatus: _AccountStatusScope.accountStatus, + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( containerIdentifier: testContainerIdentifier, - privateCloudDatabase: privateDatabase, - sharedCloudDatabase: sharedDatabase - ) - _container = container - privateDatabase.set(container: container) - sharedDatabase.set(container: container) - _syncEngine = try await SyncEngine( - container: container, - userDatabase: self.userDatabase, - delegate: _SyncEngineDelegateTrait.syncEngineDelegate, - tables: Reminder.self, - RemindersList.self, - RemindersListAsset.self, - Tag.self, - ReminderTag.self, - Parent.self, - ChildWithOnDeleteSetNull.self, - ChildWithOnDeleteSetDefault.self, - ModelA.self, - ModelB.self, - ModelC.self, - privateTables: RemindersListPrivate.self, - startImmediately: _StartImmediatelyTrait.startImmediately - ) - if _StartImmediatelyTrait.startImmediately, - _AccountStatusScope.accountStatus == .available - { - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), - syncEngine: syncEngine.private - ) - await syncEngine.handleEvent( - .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), - syncEngine: syncEngine.shared - ) - try await syncEngine.processPendingDatabaseChanges(scope: .private) - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func signOut() async { - container._accountStatus.withValue { $0 = .noAccount } - await syncEngine.handleEvent( - .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), - syncEngine: syncEngine.private - ) - await syncEngine.handleEvent( - .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), - syncEngine: syncEngine.shared + attachMetadatabase: _AttachMetadatabaseTrait.attachMetadatabase ) - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func softSignOut() async { - container._accountStatus.withValue { $0 = .temporarilyUnavailable } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func signIn() async { - container._accountStatus.withValue { $0 = .available } - // NB: Emulates what CKSyncEngine does when signing in - syncEngine.private.state.removePendingChanges() - syncEngine.shared.state.removePendingChanges() + ) + try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: _AccountStatusScope.accountStatus, + containerIdentifier: testContainerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + _syncEngine = try await SyncEngine( + container: container, + userDatabase: self.userDatabase, + delegate: _SyncEngineDelegateTrait.syncEngineDelegate, + tables: Reminder.self, + RemindersList.self, + RemindersListAsset.self, + Tag.self, + ReminderTag.self, + Parent.self, + ChildWithOnDeleteSetNull.self, + ChildWithOnDeleteSetDefault.self, + ModelA.self, + ModelB.self, + ModelC.self, + privateTables: RemindersListPrivate.self, + startImmediately: _StartImmediatelyTrait.startImmediately + ) + if _StartImmediatelyTrait.startImmediately, + _AccountStatusScope.accountStatus == .available + { await syncEngine.handleEvent( .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.private @@ -129,122 +86,157 @@ .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), syncEngine: syncEngine.shared ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signOut() async { + container._accountStatus.withValue { $0 = .noAccount } + await syncEngine.handleEvent( + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), + syncEngine: syncEngine.shared + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func softSignOut() async { + container._accountStatus.withValue { $0 = .temporarilyUnavailable } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + func signIn() async { + container._accountStatus.withValue { $0 = .available } + // NB: Emulates what CKSyncEngine does when signing in + syncEngine.private.state.removePendingChanges() + syncEngine.shared.state.removePendingChanges() + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: currentUserRecordID)), + syncEngine: syncEngine.shared + ) + } - deinit { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { - guard syncEngine.isRunning - else { return } + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + guard syncEngine.isRunning + else { return } - syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.state.assertPendingDatabaseChanges([]) - syncEngine.shared.state.assertPendingRecordZoneChanges([]) - syncEngine.shared.assertAcceptedShareMetadata([]) - syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.state.assertPendingDatabaseChanges([]) - syncEngine.private.state.assertPendingRecordZoneChanges([]) - syncEngine.private.assertAcceptedShareMetadata([]) + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) - try! syncEngine.metadatabase.read { db in - try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) - } - } else { - Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) } + } else { + Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") } } +} - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - extension SyncEngine { - var `private`: MockSyncEngine { - syncEngines.private as! MockSyncEngine +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +extension SyncEngine { + var `private`: MockSyncEngine { + syncEngines.private as! MockSyncEngine + } + var shared: MockSyncEngine { + syncEngines.shared as! MockSyncEngine + } + static nonisolated let defaultTestZone = CKRecordZone( + zoneName: "zone" + ) + convenience init< + each T1: PrimaryKeyedTable & _SendableMetatype, + each T2: PrimaryKeyedTable & _SendableMetatype + >( + container: any CloudContainer, + userDatabase: UserDatabase, + delegate: (any SyncEngineDelegate)? = nil, + tables: repeat (each T1).Type, + privateTables: repeat (each T2).Type, + startImmediately: Bool = true + ) async throws + where + repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T1).TableColumns.PrimaryColumn: WritableTableColumnExpression, + repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible, + repeat (each T2).TableColumns.PrimaryColumn: WritableTableColumnExpression + { + var allTables: [any SynchronizableTable] = [] + var allPrivateTables: [any SynchronizableTable] = [] + for table in repeat each tables { + allTables.append(SynchronizedTable(for: table)) } - var shared: MockSyncEngine { - syncEngines.shared as! MockSyncEngine + for privateTable in repeat each privateTables { + allPrivateTables.append(SynchronizedTable(for: privateTable)) } - static nonisolated let defaultTestZone = CKRecordZone( - zoneName: "zone" + try await self.init( + container: container, + userDatabase: userDatabase, + delegate: delegate, + tables: allTables, + privateTables: allPrivateTables, + startImmediately: startImmediately ) - convenience init< - each T1: PrimaryKeyedTable & _SendableMetatype, - each T2: PrimaryKeyedTable & _SendableMetatype - >( - container: any CloudContainer, - userDatabase: UserDatabase, - delegate: (any SyncEngineDelegate)? = nil, - tables: repeat (each T1).Type, - privateTables: repeat (each T2).Type, - startImmediately: Bool = true - ) async throws - where - repeat (each T1).PrimaryKey.QueryOutput: IdentifierStringConvertible, - repeat (each T1).TableColumns.PrimaryColumn: WritableTableColumnExpression, - repeat (each T2).PrimaryKey.QueryOutput: IdentifierStringConvertible, - repeat (each T2).TableColumns.PrimaryColumn: WritableTableColumnExpression - { - var allTables: [any SynchronizableTable] = [] - var allPrivateTables: [any SynchronizableTable] = [] - for table in repeat each tables { - allTables.append(SynchronizedTable(for: table)) - } - for privateTable in repeat each privateTables { - allPrivateTables.append(SynchronizedTable(for: privateTable)) - } - try await self.init( - container: container, - userDatabase: userDatabase, - delegate: delegate, - tables: allTables, - privateTables: allPrivateTables, - startImmediately: startImmediately - ) - } - convenience init( - container: any CloudContainer, - userDatabase: UserDatabase, - delegate: (any SyncEngineDelegate)? = nil, - tables: [any SynchronizableTable], - privateTables: [any SynchronizableTable] = [], - startImmediately: Bool = true - ) async throws { - try self.init( - container: container, - defaultZone: Self.defaultTestZone, - defaultSyncEngines: { _, syncEngine in - ( - MockSyncEngine( - database: container.privateCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() - ), - MockSyncEngine( - database: container.sharedCloudDatabase as! MockCloudDatabase, - parentSyncEngine: syncEngine, - state: MockSyncEngineState() - ) + } + convenience init( + container: any CloudContainer, + userDatabase: UserDatabase, + delegate: (any SyncEngineDelegate)? = nil, + tables: [any SynchronizableTable], + privateTables: [any SynchronizableTable] = [], + startImmediately: Bool = true + ) async throws { + try self.init( + container: container, + defaultZone: Self.defaultTestZone, + defaultSyncEngines: { _, syncEngine in + ( + MockSyncEngine( + database: container.privateCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() + ), + MockSyncEngine( + database: container.sharedCloudDatabase as! MockCloudDatabase, + parentSyncEngine: syncEngine, + state: MockSyncEngineState() ) - }, - userDatabase: userDatabase, - logger: Logger(.disabled), - delegate: delegate, - tables: tables, - privateTables: privateTables - ) - try setUpSyncEngine() - if startImmediately { - try await start() - } + ) + }, + userDatabase: userDatabase, + logger: Logger.disabled, + delegate: delegate, + tables: tables, + privateTables: privateTables + ) + try setUpSyncEngine() + if startImmediately { + try await start() } } +} - private let previousUserRecordID = CKRecord.ID( - recordName: "previousUser" - ) - private let currentUserRecordID = CKRecord.ID( - recordName: "currentUser" - ) +private let previousUserRecordID = CKRecord.ID( + recordName: "previousUser" +) +private let currentUserRecordID = CKRecord.ID( + recordName: "currentUser" +) - // NB: This conformance is only used for ease of testing. In general it is not appropriate to - // conform integer types to this protocol. - extension Int: IdentifierStringConvertible {} -#endif +// NB: This conformance is only used for ease of testing. In general it is not appropriate to +// conform integer types to this protocol. +extension Int: IdentifierStringConvertible {}