From a66421b5638b44b6c385d9044e02859e4b42b9a4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 8 Jul 2026 20:32:51 -0500 Subject: [PATCH 1/9] Use LockIsolated in FetchBox --- .../Dependencies/SoundEffectClient.swift | 4 +- Package.swift | 2 - Package@swift-6.0.swift | 2 - .../SQLiteData/CloudKit/CloudKitSharing.swift | 3 +- .../CloudKit/Internal/DataManager.swift | 7 +- .../Internal/MockCloudContainer.swift | 11 ++- .../CloudKit/Internal/MockCloudDatabase.swift | 9 +-- .../CloudKit/Internal/MockSyncEngine.swift | 35 +++++---- Sources/SQLiteData/CloudKit/SyncEngine.swift | 53 +++++++------ Sources/SQLiteData/FetchSubscription.swift | 5 +- Sources/SQLiteData/Internal/FetchBox.swift | 75 +++++++++---------- Sources/SQLiteData/Internal/FetchKey.swift | 1 - .../SQLiteData/Internal/LockIsolated.swift | 16 ++++ .../CloudKitTests/AccountLifecycleTests.swift | 1 - .../CloudKitTests/AssetsTests.swift | 5 +- .../CloudKitTests/AtomicTests.swift | 1 - .../AttachedMetadatabaseTests.swift | 2 - .../CloudKitTests/CloudKitTests.swift | 1 - .../ForeignKeyConstraintTests.swift | 12 +-- .../CloudKitTests/MergeConflictTests.swift | 2 +- .../CloudKitTests/MetadataTests.swift | 1 - .../MockCloudDatabaseTests.swift | 9 +-- .../CloudKitTests/NewTableSyncTests.swift | 1 - .../NextRecordZoneChangeBatchTests.swift | 1 - .../CloudKitTests/RecordTypeTests.swift | 1 - .../ReferenceViolationTests.swift | 1 - .../CloudKitTests/SchemaChangeTests.swift | 4 +- .../SyncEngineDelegateTests.swift | 5 +- .../SyncEngineLifecycleTests.swift | 1 - .../FetchSubscriptionTests.swift | 6 +- .../Internal/BaseCloudKitTests.swift | 11 ++- .../Internal/CloudKit+CustomDump.swift | 15 ++-- .../Internal/CloudKitTestHelpers.swift | 11 ++- 33 files changed, 151 insertions(+), 163 deletions(-) create mode 100644 Sources/SQLiteData/Internal/LockIsolated.swift diff --git a/Examples/SyncUps/Dependencies/SoundEffectClient.swift b/Examples/SyncUps/Dependencies/SoundEffectClient.swift index c59852a8..9b040c0c 100644 --- a/Examples/SyncUps/Dependencies/SoundEffectClient.swift +++ b/Examples/SyncUps/Dependencies/SoundEffectClient.swift @@ -11,14 +11,14 @@ nonisolated struct SoundEffectClient: DependencyKey { let player = LockIsolated(AVPlayer()) return Self( load: { fileName in - player.withValue { + player.withLock { guard let url = Bundle.main.url(forResource: fileName, withExtension: "") else { return } $0.replaceCurrentItem(with: AVPlayerItem(url: url)) } }, play: { - player.withValue { + player.withLock { $0.seek(to: .zero) $0.play() } diff --git a/Package.swift b/Package.swift index 837ac528..aca3d23d 100644 --- a/Package.swift +++ b/Package.swift @@ -60,7 +60,6 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), @@ -79,7 +78,6 @@ let package = Package( name: "SQLiteDataTestSupport", dependencies: [ "SQLiteData", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "ConcurrencyExtrasTestSupport", package: "swift-concurrency-extras"), .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 66138923..19a896ec 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -35,7 +35,6 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), @@ -48,7 +47,6 @@ let package = Package( name: "SQLiteDataTestSupport", dependencies: [ "SQLiteData", - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), diff --git a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift index 8451fa7e..a6c497dd 100644 --- a/Sources/SQLiteData/CloudKit/CloudKitSharing.swift +++ b/Sources/SQLiteData/CloudKit/CloudKitSharing.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) public import CloudKit - import ConcurrencyExtras import GRDB import IssueReporting public import StructuredQueries @@ -241,7 +240,7 @@ } func unshare(share: CKShare) async throws { - let result = try await syncEngines.private?.database.modifyRecords( + let result = try await syncEngines.withLock(\.private)?.database.modifyRecords( saving: [], deleting: [share.recordID] ) diff --git a/Sources/SQLiteData/CloudKit/Internal/DataManager.swift b/Sources/SQLiteData/CloudKit/Internal/DataManager.swift index 9a67b42e..78c6b6b0 100644 --- a/Sources/SQLiteData/CloudKit/Internal/DataManager.swift +++ b/Sources/SQLiteData/CloudKit/Internal/DataManager.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) && canImport(CryptoKit) - package import ConcurrencyExtras import CryptoKit import Dependencies package import Foundation @@ -55,7 +54,7 @@ package init() {} package func load(_ url: URL) throws -> Data { - try storage.withValue { storage in + try storage.withLock { storage in guard let data = storage[url] else { struct FileNotFound: Error {} @@ -66,11 +65,11 @@ } package func save(_ data: Data, to url: URL) throws { - storage.withValue { $0[url] = data } + storage.withLock { $0[url] = data } } package func sha256(of fileURL: URL) -> Data? { - storage.withValue { + storage.withLock { $0[fileURL].map { Data(SHA256.hash(data: $0)) } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index 3af32acf..1ca58c0c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) - package import ConcurrencyExtras package import CloudKit import Dependencies @@ -23,13 +22,13 @@ guard let containerIdentifier else { return } @Dependency(\.mockCloudContainers) var mockCloudContainers - mockCloudContainers.withValue { storage in + mockCloudContainers.withLock { storage in storage[containerIdentifier] = self } } package func accountStatus() -> CKAccountStatus { - _accountStatus.withValue(\.self) + _accountStatus.withLock(\.self) } package var rawValue: CKContainer { @@ -37,7 +36,7 @@ } package func accountStatus() async throws -> CKAccountStatus { - _accountStatus.withValue { $0 } + _accountStatus.withLock { $0 } } package func shareMetadata( @@ -49,7 +48,7 @@ ? privateCloudDatabase : sharedCloudDatabase - let rootRecord: CKRecord? = database.state.withValue { + let rootRecord: CKRecord? = database.state.withLock { $0.storage[share.recordID.zoneID]?.records.values.first { record in record.share?.recordID == share.recordID } @@ -80,7 +79,7 @@ -> MockCloudContainer { @Dependency(\.mockCloudContainers) var mockCloudContainers - return mockCloudContainers.withValue { storage in + return mockCloudContainers.withLock { storage in let container: MockCloudContainer if let existingContainer = storage[containerIdentifier] { return existingContainer diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 510d1f5c..51daf55f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) - package import ConcurrencyExtras package import CloudKit import Dependencies import IssueReporting @@ -48,7 +47,7 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } - let record = try state.withValue { state in + let record = try state.withLock { state in guard let zone = state.storage[recordID.zoneID] else { throw CKError(.zoneNotFound) } guard let record = zone.records[recordID] @@ -58,7 +57,7 @@ return record } - try state.withValue { state in + try state.withLock { state in for key in record.allKeys() { guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] else { continue } @@ -107,7 +106,7 @@ throw CKError(.limitExceeded) } - return state.withValue { state in + return state.withLock { state in let previousStorage = state.storage var saveResults: [CKRecord.ID: Result] = [:] var deleteResults: [CKRecord.ID: Result] = [:] @@ -367,7 +366,7 @@ guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } - return state.withValue { state in + return state.withLock { state in var saveResults: [CKRecordZone.ID: Result] = [:] var deleteResults: [CKRecordZone.ID: Result] = [:] diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index c8ef0113..82c9c476 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) - package import ConcurrencyExtras package import CloudKit import IssueReporting package import OrderedCollections @@ -27,7 +26,7 @@ } package func acceptShare(metadata: ShareMetadata) { - _ = _acceptedShareMetadata.withValue { $0.insert(metadata) } + _ = _acceptedShareMetadata.withLock { $0.insert(metadata) } } package func fetchChanges(_ options: CKSyncEngine.FetchChangesOptions) async throws { @@ -35,16 +34,16 @@ let zoneIDs: [CKRecordZone.ID] switch options.scope { case .all: - zoneIDs = Array(database.state.storage.keys) + zoneIDs = Array(database.state.withLock(\.storage.keys)) case .allExcluding(let excludedZoneIDs): - zoneIDs = Array(Set(database.state.storage.keys).subtracting(excludedZoneIDs)) + zoneIDs = Array(Set(database.state.withLock(\.storage.keys)).subtracting(excludedZoneIDs)) case .zoneIDs(let includedZoneIDs): zoneIDs = includedZoneIDs @unknown default: fatalError() } - modifications = database.state.withValue { state in + modifications = database.state.withLock { state in zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in @@ -54,12 +53,12 @@ $0._recordChangeTag != nil, "Records stored in database should have their 'recordChangeTag' assigned." ) - return $0._recordChangeTag! > self.state.changeTag.value + return $0._recordChangeTag! > self.state.changeTag.withLock(\.self) } } } - let deletions = database.state.withValue { + let deletions = database.state.withLock { let records = $0.deletedRecords.filter { recordID, _ in zoneIDs.contains(recordID.zoneID) } @@ -72,7 +71,7 @@ guard !modifications.isEmpty || !deletions.isEmpty else { return } - state.changeTag.withValue { changeTag in + state.changeTag.withLock { changeTag in changeTag = modifications.compactMap(\._recordChangeTag).max() ?? changeTag } @@ -161,38 +160,38 @@ } package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue { Array($0) } + _pendingRecordZoneChanges.withLock { Array($0) } } package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue { Array($0) } + _pendingDatabaseChanges.withLock { Array($0) } } package func removePendingChanges() { - _pendingDatabaseChanges.withValue { $0.removeAll() } - _pendingRecordZoneChanges.withValue { $0.removeAll() } + _pendingDatabaseChanges.withLock { $0.removeAll() } + _pendingRecordZoneChanges.withLock { $0.removeAll() } } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { + self._pendingRecordZoneChanges.withLock { $0.append(contentsOf: pendingRecordZoneChanges) } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { + self._pendingRecordZoneChanges.withLock { $0.subtract(pendingRecordZoneChanges) } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { + self._pendingDatabaseChanges.withLock { $0.append(contentsOf: pendingDatabaseChanges) } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { + self._pendingDatabaseChanges.withLock { $0.subtract(pendingDatabaseChanges) } } @@ -439,10 +438,10 @@ } package var `private`: MockSyncEngine { - syncEngines.private as! MockSyncEngine + syncEngines.withLock(\.private) as! MockSyncEngine } package var shared: MockSyncEngine { - syncEngines.shared as! MockSyncEngine + syncEngines.withLock(\.shared) as! MockSyncEngine } package func syncEngine(for scope: CKDatabase.Scope) -> MockSyncEngine { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index ddcb3f32..42b115d7 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) public import CloudKit - package import ConcurrencyExtras import Dependencies public import GRDB public import IssueReporting @@ -282,7 +281,7 @@ ) #if os(iOS) @Dependency(\.defaultNotificationCenter) var defaultNotificationCenter - notificationsObserver.withValue { + notificationsObserver.withLock { $0 = defaultNotificationCenter.addObserver( forName: UIApplication.willResignActiveNotification, object: nil, @@ -291,7 +290,7 @@ _ = Task { @MainActor in let taskIdentifier = UIApplication.shared.beginBackgroundTask() defer { UIApplication.shared.endBackgroundTask(taskIdentifier) } - let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue { + let (privateSyncEngine, sharedSyncEngine) = syncEngines.withLock { ($0.private, $0.shared) } try await privateSyncEngine?.sendChanges(CKSyncEngine.SendChangesOptions()) @@ -304,7 +303,7 @@ } deinit { - notificationsObserver.withValue { + notificationsObserver.withLock { guard let observer = $0 else { return } NotificationCenter.default.removeObserver(observer) @@ -430,13 +429,13 @@ public func stop() { guard isRunning else { return } #if DEBUG && canImport(DeveloperToolsSupport) - previewTimerTask.withValue { + previewTimerTask.withLock { $0?.cancel() $0 = nil } #endif observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { - syncEngines.withValue { + syncEngines.withLock { $0 = SyncEngines() } } @@ -445,7 +444,7 @@ /// Determines if the sync engine is currently running or not. public var isRunning: Bool { observationRegistrar.access(self, keyPath: \.isRunning) - return syncEngines.withValue { + return syncEngines.withLock { $0.isRunning } } @@ -453,7 +452,7 @@ private func start() throws -> Task { guard !isRunning else { return Task {} } observationRegistrar.withMutation(of: self, keyPath: \.isRunning) { - syncEngines.withValue { + syncEngines.withLock { let (privateSyncEngine, sharedSyncEngine) = defaultSyncEngines(metadatabase, self) $0 = SyncEngines( private: privateSyncEngine, @@ -513,7 +512,7 @@ @Dependency(\.context) var context @Dependency(\.continuousClock) var clock if context == .preview { - previewTimerTask.withValue { + previewTimerTask.withLock { $0?.cancel() $0 = Task { @Sendable [weak self] in await withErrorReporting { @@ -531,7 +530,7 @@ await withErrorReporting(.sqliteDataCloudKitFailure) { guard try await container.accountStatus() == .available else { return } - syncEngines.withValue { + syncEngines.withLock { $0.private?.state.add(pendingDatabaseChanges: [.saveZone(defaultZone)]) } try await uploadRecordsToCloudKit( @@ -545,7 +544,7 @@ try await cacheUserTables(recordTypes: currentRecordTypes) } } - self.startTask.withValue { + self.startTask.withLock { $0?.cancel() $0 = startTask } @@ -563,8 +562,8 @@ public func fetchChanges( _ options: CKSyncEngine.FetchChangesOptions = CKSyncEngine.FetchChangesOptions() ) async throws { - await startTask.withValue(\.self)?.value - let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue { + await startTask.withLock(\.self)?.value + let (privateSyncEngine, sharedSyncEngine) = syncEngines.withLock { ($0.private, $0.shared) } guard let privateSyncEngine, let sharedSyncEngine @@ -585,8 +584,8 @@ public func sendChanges( _ options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions() ) async throws { - await startTask.withValue(\.self)?.value - let (privateSyncEngine, sharedSyncEngine) = syncEngines.withValue { + await startTask.withLock(\.self)?.value + let (privateSyncEngine, sharedSyncEngine) = syncEngines.withLock { ($0.private, $0.shared) } guard let privateSyncEngine, let sharedSyncEngine @@ -656,7 +655,7 @@ false } } - syncEngines.withValue { + syncEngines.withLock { $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) } @@ -837,10 +836,10 @@ } return } - let oldSyncEngine = self.syncEngines.withValue { + let oldSyncEngine = self.syncEngines.withLock { oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - let syncEngine = self.syncEngines.withValue { + let syncEngine = self.syncEngines.withLock { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) @@ -878,7 +877,7 @@ return } - let syncEngine = self.syncEngines.withValue { + let syncEngine = self.syncEngines.withLock { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } syncEngine?.state.add(pendingRecordZoneChanges: changes) @@ -892,7 +891,7 @@ } let container = type(of: container).createContainer(identifier: metadata.containerIdentifier) _ = try await container.accept(metadata) - try await syncEngines.shared?.fetchChanges( + try await syncEngines.withLock(\.shared)?.fetchChanges( CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([rootRecordID.zoneID]), operationGroup: nil @@ -924,22 +923,22 @@ private var sendingChangesCount: Int { get { observationRegistrar.access(self, keyPath: \.isSendingChanges) - return activityCounts.withValue(\.sendingChangesCount) + return activityCounts.withLock(\.sendingChangesCount) } set { observationRegistrar.withMutation(of: self, keyPath: \.isSendingChanges) { - activityCounts.withValue { $0.sendingChangesCount = newValue } + activityCounts.withLock { $0.sendingChangesCount = newValue } } } } private var fetchingChangesCount: Int { get { observationRegistrar.access(self, keyPath: \.isFetchingChanges) - return activityCounts.withValue(\.fetchingChangesCount) + return activityCounts.withLock(\.fetchingChangesCount) } set { observationRegistrar.withMutation(of: self, keyPath: \.isFetchingChanges) { - activityCounts.withValue { $0.fetchingChangesCount = newValue } + activityCounts.withLock { $0.fetchingChangesCount = newValue } } } } @@ -1116,7 +1115,7 @@ #if DEBUG let state = LockIsolated(NextRecordZoneChangeBatchLoggingState()) defer { - let state = state.withValue(\.self) + let state = state.withLock(\.self) if let tabularDescription = state.tabularDescription { logger.debug( """ @@ -1153,7 +1152,7 @@ var sentRecord: CKRecord.ID? #if DEBUG defer { - state.withValue { [missingTable, missingRecord, sentRecord] in + state.withLock { [missingTable, missingRecord, sentRecord] in if let missingTable { $0.events.append("⚠️ Missing table") $0.recordTypes.append(metadata.recordType) @@ -1345,7 +1344,7 @@ changeType: CKSyncEngine.Event.AccountChange.ChangeType, syncEngine: any SyncEngineProtocol ) async { - guard syncEngine === syncEngines.private + guard syncEngine === syncEngines.withLock(\.private) else { return } switch changeType { diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index de794216..ccf76719 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -1,4 +1,3 @@ -import ConcurrencyExtras import Perception import Sharing @@ -35,13 +34,13 @@ public struct FetchSubscription: Sendable { onCancel() } } - cancellable.withValue { $0 = task } + cancellable.withLock { $0 = task } try await task.cancellableValue } } /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public func cancel() { - cancellable.value?.cancel() + cancellable.withLock { $0?.cancel() } } } diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index 9170223f..7ea0ba05 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -5,66 +5,65 @@ import Sharing import SwiftUI - final class FetchBox: @unchecked Sendable { - private let lock = NSLock() - private var storage: Storage + final class FetchBox: Sendable { + private let storage: LockIsolated init(sharedReader: SharedReader) { - storage = Storage(sharedReader: sharedReader) + storage = LockIsolated(Storage(sharedReader: sharedReader)) } var sharedReader: SharedReader { - get { lock.withLock { storage.sharedReader } } - set { lock.withLock { storage.sharedReader = newValue } } + get { storage.withLock { $0.sharedReader } } + set { storage.withLock { $0.sharedReader = newValue } } } var fetchKeyID: FetchKeyID? { - get { lock.withLock { storage.fetchKeyID } } - set { lock.withLock { storage.fetchKeyID = newValue } } + get { storage.withLock { $0.fetchKeyID } } + set { storage.withLock { $0.fetchKeyID = newValue } } } func reconcile(from fresh: FetchBox, propertyName: String) { - let freshSnapshot = fresh.lock.withLock { fresh.storage } - let snapshot = lock.withLock { storage } - if let freshFetchKeyID = freshSnapshot.fetchKeyID { - if freshFetchKeyID != snapshot.fetchKeyID { - update(from: freshSnapshot) - } - } else if snapshot.fetchKeyID != nil { - #if DEBUG - let hasReported = lock.withLock { - defer { storage.hasReportedIgnoredReinitialization = true } - return storage.hasReportedIgnoredReinitialization + fresh.storage.withLock { freshSnapshot in + storage.withLock { snapshot in + if let freshFetchKeyID = freshSnapshot.fetchKeyID { + if freshFetchKeyID != snapshot.fetchKeyID { + update(from: freshSnapshot) + } + } else if snapshot.fetchKeyID != nil { + #if DEBUG + defer { snapshot.hasReportedIgnoredReinitialization = true } + guard !snapshot.hasReportedIgnoredReinitialization else { return } + reportIssue( + """ + A '\(propertyName)' property was re-initialized without a query, but was previously \ + initialized with one; this re-initialization will be ignored, and the property \ + will continue to observe the existing query + """ + ) + #endif + } else if isEqual(freshSnapshot.initialValue, snapshot.initialValue) == false { + update(from: freshSnapshot) } - guard !hasReported else { return } - reportIssue( - """ - A '\(propertyName)' property was re-initialized without a query, but was previously \ - initialized with one; this re-initialization will be ignored, and the property \ - will continue to observe the existing query - """ - ) - #endif - } else if isEqual(freshSnapshot.initialValue, snapshot.initialValue) == false { - update(from: freshSnapshot) + } } } private func update(from other: Storage) { - lock.withLock { - storage.sharedReader = other.sharedReader - storage.fetchKeyID = other.fetchKeyID - storage.initialValue = other.initialValue + storage.withLock { + $0.sharedReader = other.sharedReader + $0.fetchKeyID = other.fetchKeyID + $0.initialValue = other.initialValue } } func subscribe(generation: SwiftUI.State) { guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } _ = generation.wrappedValue - let cancellable = sharedReader.publisher - .dropFirst() - .sink { _ in generation.wrappedValue &+= 1 } - lock.withLock { storage.swiftUICancellable = cancellable } + storage.withLock { + $0.swiftUICancellable = $0.sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + } } private struct Storage { diff --git a/Sources/SQLiteData/Internal/FetchKey.swift b/Sources/SQLiteData/Internal/FetchKey.swift index 8bab73bf..1ed414cd 100644 --- a/Sources/SQLiteData/Internal/FetchKey.swift +++ b/Sources/SQLiteData/Internal/FetchKey.swift @@ -1,4 +1,3 @@ -import ConcurrencyExtras import Dependencies import Dispatch import Foundation diff --git a/Sources/SQLiteData/Internal/LockIsolated.swift b/Sources/SQLiteData/Internal/LockIsolated.swift new file mode 100644 index 00000000..ed411ac4 --- /dev/null +++ b/Sources/SQLiteData/Internal/LockIsolated.swift @@ -0,0 +1,16 @@ +import class Foundation.NSLock + +package final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSLock() + package init(_ value: sending Value) { + self._value = value + } + package func withLock( + _ operation: (inout sending Value) throws(F) -> sending R + ) throws(F) -> sending R { + lock.lock() + defer { lock.unlock() } + return try operation(&_value) + } +} diff --git a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift index dd8f0ef9..b93173cc 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AccountLifecycleTests.swift @@ -1,5 +1,4 @@ #if canImport(CloudKit) - import ConcurrencyExtrasTestSupport import CloudKit import CustomDump import Foundation diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift index 467e85f3..6951da7b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections @@ -59,7 +58,7 @@ """ } - inMemoryDataManager.storage.withValue { storage in + inMemoryDataManager.storage.withLock { storage in let url = URL( string: "file:///tmp/6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d" )! @@ -115,7 +114,7 @@ """ } - inMemoryDataManager.storage.withValue { storage in + inMemoryDataManager.storage.withLock { storage in let url = URL( string: "file:///tmp/97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf" )! diff --git a/Tests/SQLiteDataTests/CloudKitTests/AtomicTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AtomicTests.swift index 4b17da1d..1dc55b3a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AtomicTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AtomicTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections diff --git a/Tests/SQLiteDataTests/CloudKitTests/AttachedMetadatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AttachedMetadatabaseTests.swift index dbec8bb2..df8a98fa 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/AttachedMetadatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/AttachedMetadatabaseTests.swift @@ -1,7 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras - import ConcurrencyExtrasTestSupport import CustomDump import InlineSnapshotTesting import OrderedCollections diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index fa989369..75cb5c6f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 071688d1..d20b52de 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -672,9 +672,9 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ - Reminder.recordID(for: 1) - ], + of: syncEngine.private.database.state.withLock { + $0.storage[syncEngine.defaultZone.zoneID]?.records[Reminder.recordID(for: 1)] + }, as: .customDump ) { """ @@ -767,9 +767,9 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ - Reminder.recordID(for: 1) - ], + of: syncEngine.private.database.state.withLock { + $0.storage[syncEngine.defaultZone.zoneID]?.records[Reminder.recordID(for: 1)] + }, as: .customDump ) { """ diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index b9840148..ceb1a3f2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1,6 +1,6 @@ #if canImport(CloudKit) - import CloudKit import ConcurrencyExtrasTestSupport + import CloudKit import CustomDump import Foundation import InlineSnapshotTesting diff --git a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift index bd81326d..945b33da 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MetadataTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtrasTestSupport import CustomDump import SQLiteDataTestSupport import Foundation diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index d019ae9e..fca888db 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import OrderedCollections @@ -321,7 +320,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func accountTemporarilyAvailable() async throws { - container._accountStatus.withValue { $0 = .temporarilyUnavailable } + container._accountStatus.withLock { $0 = .temporarilyUnavailable } var error = #expect(throws: CKError.self) { _ = try self.syncEngine.private.database.modifyRecordZones() } @@ -344,7 +343,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func noAccount() async throws { - container._accountStatus.withValue { $0 = .noAccount } + container._accountStatus.withLock { $0 = .noAccount } var error = #expect(throws: CKError.self) { _ = try self.syncEngine.private.database.modifyRecordZones() } @@ -367,7 +366,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func accountNotDetermined() async throws { - container._accountStatus.withValue { $0 = .couldNotDetermine } + container._accountStatus.withLock { $0 = .couldNotDetermine } var error = #expect(throws: CKError.self) { _ = try self.syncEngine.private.database.modifyRecordZones() } @@ -390,7 +389,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func restrictedAccount() async throws { - container._accountStatus.withValue { $0 = .restricted } + container._accountStatus.withLock { $0 = .restricted } var error = #expect(throws: CKError.self) { _ = try self.syncEngine.private.database.modifyRecordZones() } diff --git a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift index 568184a5..0bdb99df 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NewTableSyncTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtrasTestSupport import CustomDump import SQLiteDataTestSupport import Foundation diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index 8a3c2066..d8fbbed2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtrasTestSupport import CustomDump import Foundation import InlineSnapshotTesting diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 2891b3e2..a9b494c6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import SQLiteData diff --git a/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift index eaabf060..c3871cbe 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtras import CustomDump import InlineSnapshotTesting import SQLiteData diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index 98601572..e93e5c35 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -791,7 +791,7 @@ ) .notify() - inMemoryDataManager.storage.withValue { $0.removeAll() } + inMemoryDataManager.storage.withLock { $0.removeAll() } try await userDatabase.userWrite { db in try #sql( @@ -848,7 +848,7 @@ .notify() syncEngine.stop() - inMemoryDataManager.storage.withValue { $0.removeAll() } + inMemoryDataManager.storage.withLock { $0.removeAll() } try await userDatabase.userWrite { db in try #sql( diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift index 395be01e..ba97ea2b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtrasTestSupport import CustomDump import DependenciesTestSupport import Foundation @@ -229,10 +228,10 @@ _ syncEngine: SQLiteData.SyncEngine, accountChanged changeType: CKSyncEngine.Event.AccountChange.ChangeType ) async { - wasCalled.withValue { $0 = true } + wasCalled.withLock { $0 = true } } deinit { - guard wasCalled.withValue(\.self) + guard wasCalled.withLock(\.self) else { Issue.record("Delegate method 'syncEngine(_:accountChanged:)' was not called.") return diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index 229d6b02..e651ba27 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -1,6 +1,5 @@ #if canImport(CloudKit) import CloudKit - import ConcurrencyExtrasTestSupport import DependenciesTestSupport import InlineSnapshotTesting import SQLiteDataTestSupport diff --git a/Tests/SQLiteDataTests/FetchSubscriptionTests.swift b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift index 155dcdc2..25c3702d 100644 --- a/Tests/SQLiteDataTests/FetchSubscriptionTests.swift +++ b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift @@ -31,7 +31,7 @@ import Testing @Test func completeWhenTaskExplicitlyCancelled() async throws { @FetchAll var records: [Record] #expect(records.count == 0) - let didComplete = LockIsolated(false) + nonisolated(unsafe) var didComplete = false try await database.write { db in try Record.insert { Record.Draft() }.execute(db) @@ -43,14 +43,14 @@ import Testing let task = Task { try? await subscription.task - didComplete.withValue { $0 = true } + didComplete = true } try await Task.sleep(for: .seconds(1)) subscription.cancel() await task.value - #expect(didComplete.value) + #expect(didComplete) } @Test func cancellingOneFetchDoesNotCancelAnother() async throws { diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index f55e5a64..ea6163f4 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -1,7 +1,6 @@ #if canImport(CloudKit) import Clocks import CloudKit - import ConcurrencyExtrasTestSupport import DependenciesTestSupport import OrderedCollections import SQLiteData @@ -100,7 +99,7 @@ import TestLocals @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func signOut() async { - container._accountStatus.withValue { $0 = .noAccount } + container._accountStatus.withLock { $0 = .noAccount } await syncEngine.handleEvent( .accountChange(changeType: .signOut(previousUser: previousUserRecordID)), syncEngine: syncEngine.private @@ -113,12 +112,12 @@ import TestLocals @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func softSignOut() async { - container._accountStatus.withValue { $0 = .temporarilyUnavailable } + container._accountStatus.withLock { $0 = .temporarilyUnavailable } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) func signIn() async { - container._accountStatus.withValue { $0 = .available } + container._accountStatus.withLock { $0 = .available } // NB: Emulates what CKSyncEngine does when signing in syncEngine.private.state.removePendingChanges() syncEngine.shared.state.removePendingChanges() @@ -158,10 +157,10 @@ import TestLocals @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { var `private`: MockSyncEngine { - syncEngines.private as! MockSyncEngine + syncEngines.withLock(\.private) as! MockSyncEngine } var shared: MockSyncEngine { - syncEngines.shared as! MockSyncEngine + syncEngines.withLock(\.shared) as! MockSyncEngine } static nonisolated let defaultTestZone = CKRecordZone( zoneName: "zone" diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 16d1903b..e4aab1e4 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -175,13 +175,13 @@ children: [ ( "pendingRecordZoneChanges", - _pendingRecordZoneChanges.withValue(\.self) + _pendingRecordZoneChanges.withLock(\.self) .sorted(by: comparePendingRecordZoneChange) as Any ), ( "pendingDatabaseChanges", - _pendingDatabaseChanges.withValue(\.self) + _pendingDatabaseChanges.withLock(\.self) .sorted(by: comparePendingDatabaseChange) as Any ), ], @@ -249,11 +249,12 @@ children: [ "databaseScope": databaseScope, "storage": state - .value - .storage - .flatMap { _, value in value.records.values } - .sorted { - ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + .withLock { + $0.storage + .flatMap { _, value in value.records.values } + .sorted { + ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + } }, ], displayStyle: .struct diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 4716be7b..580f512f 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -1,5 +1,4 @@ import CloudKit -import ConcurrencyExtras import CustomDump import OrderedCollections import SQLiteData @@ -75,7 +74,7 @@ extension SyncEngine { > { let syncEngine = syncEngine(for: scope) let recordsToDeleteByID = Dictionary( - grouping: syncEngine.database.state.withValue { state in + grouping: syncEngine.database.state.withLock { state in recordIDsToDelete.compactMap { recordID in state.storage[recordID.zoneID]?.records[recordID] } @@ -118,7 +117,7 @@ extension MockSyncEngine { line: UInt = #line, column: UInt = #column ) { - _fetchChangesScopes.withValue { + _fetchChangesScopes.withLock { expectNoDifference( scopes, $0, @@ -138,7 +137,7 @@ extension MockSyncEngine { line: UInt = #line, column: UInt = #column ) { - _acceptedShareMetadata.withValue { + _acceptedShareMetadata.withLock { expectNoDifference( sharedMetadata, $0, @@ -161,7 +160,7 @@ extension MockSyncEngineState { line: UInt = #line, column: UInt = #column ) { - _pendingRecordZoneChanges.withValue { + _pendingRecordZoneChanges.withLock { expectNoDifference( Set(changes), Set($0), @@ -181,7 +180,7 @@ extension MockSyncEngineState { line: UInt = #line, column: UInt = #column ) { - _pendingDatabaseChanges.withValue { + _pendingDatabaseChanges.withLock { expectNoDifference( Set(changes), Set($0), From 47064ee703ee4991f36aa64428eed96f9723285d Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 16:02:44 -0700 Subject: [PATCH 2/9] fix --- Sources/SQLiteData/FetchSubscription.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index ccf76719..7fe99b67 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -35,7 +35,7 @@ public struct FetchSubscription: Sendable { } } cancellable.withLock { $0 = task } - try await task.cancellableValue + try await task.value } } From d88fbedc0210789244a9ee09856f900f0bea3f3a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 16:36:54 -0700 Subject: [PATCH 3/9] fix --- Examples/Examples.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0040127d..6b2baad5 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1100,6 +1100,7 @@ isa = XCLocalSwiftPackageReference; relativePath = ..; traits = ( + CasePaths, LazyInitializableByDefault, ); }; @@ -1141,6 +1142,8 @@ minimumVersion = 2.2.3; }; traits = ( + CasePaths, + IssueReporting, ); }; /* End XCRemoteSwiftPackageReference section */ From d112f258f035e76ab0b8d4dec66dc2c7d9dbd2da Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 16:38:22 -0700 Subject: [PATCH 4/9] Bump CI Xcode --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe67147..26a544c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ["26.2"] + xcode: ["26.6"] config: ["debug", "release"] runs-on: macos-26 steps: @@ -32,7 +32,7 @@ jobs: name: Examples strategy: matrix: - xcode: ["26.2"] + xcode: ["26.6"] config: ["debug"] scheme: ["Reminders", "CaseStudies", "SyncUps"] runs-on: macos-26 From 023d7da0bce1039fe51989299fb8b5b96cd049b5 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 16:47:53 -0700 Subject: [PATCH 5/9] Fix? --- Examples/Examples.xcodeproj/project.pbxproj | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 6b2baad5..5dc5bdb5 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1122,9 +1122,6 @@ kind = upToNextMajorVersion; minimumVersion = 1.7.0; }; - traits = ( - Clocks, - ); }; DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { isa = XCRemoteSwiftPackageReference; @@ -1141,10 +1138,6 @@ kind = upToNextMajorVersion; minimumVersion = 2.2.3; }; - traits = ( - CasePaths, - IssueReporting, - ); }; /* End XCRemoteSwiftPackageReference section */ From cd894ef4c337b31883428a220674227c7576c25b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 16:57:59 -0700 Subject: [PATCH 6/9] wip --- Sources/SQLiteData/FetchSubscription.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index 7fe99b67..4508c66a 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -29,7 +29,9 @@ public struct FetchSubscription: Sendable { get async throws { let task = Task { try await withTaskCancellationHandler { - try await Task.never() + let never = AsyncStream { _ in } + for await _ in never {} + throw CancellationError() } onCancel: { onCancel() } From 46b3d48e480ebfac75f9ce615de0d89273e61b43 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 17:54:19 -0700 Subject: [PATCH 7/9] Revert library test CI runner change --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a544c4..1a52aaaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ["26.6"] + xcode: ["26.2"] config: ["debug", "release"] runs-on: macos-26 steps: From cb0db6b751441899fb521a84248fecc88dcaff95 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 17:59:15 -0700 Subject: [PATCH 8/9] Smaller bump --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a52aaaf..99d91eec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ["26.2"] + xcode: ["26.5"] config: ["debug", "release"] runs-on: macos-26 steps: From 08590d4b5b63e5e2dde9d7e5bc92fb041f1a7215 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 18:46:44 -0700 Subject: [PATCH 9/9] fix --- .github/workflows/ci.yml | 2 +- Sources/SQLiteData/FetchSubscription.swift | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d91eec..26a544c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ["26.5"] + xcode: ["26.6"] config: ["debug", "release"] runs-on: macos-26 steps: diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index 4508c66a..00644b50 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -14,7 +14,7 @@ import Sharing /// } /// ``` public struct FetchSubscription: Sendable { - let cancellable = LockIsolated?>(nil) + let cancellable = LockIsolated.Continuation?>(nil) let onCancel: @Sendable () -> Void init(sharedReader: SharedReader) { @@ -27,22 +27,17 @@ public struct FetchSubscription: Sendable { /// the observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public var task: Void { get async throws { - let task = Task { - try await withTaskCancellationHandler { - let never = AsyncStream { _ in } - for await _ in never {} - throw CancellationError() - } onCancel: { - onCancel() - } + let never = AsyncStream { continuation in + cancellable.withLock { $0 = continuation } } - cancellable.withLock { $0 = task } - try await task.value + for await _ in never {} + onCancel() + throw CancellationError() } } /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public func cancel() { - cancellable.withLock { $0?.cancel() } + cancellable.withLock { $0?.finish() } } }