From 64a1b6938e591dd73eb479f9bebc1ba76411ed62 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 6 Jul 2026 16:34:48 -0700 Subject: [PATCH 1/5] wip --- Sources/SQLiteData/Fetch.swift | 54 +++++++- Sources/SQLiteData/FetchAll.swift | 81 +++++++---- Sources/SQLiteData/FetchOne.swift | 149 +++++++++++++-------- Sources/SQLiteData/Internal/FetchBox.swift | 96 +++++++++++++ Tests/SQLiteDataTests/FetchBoxTests.swift | 99 ++++++++++++++ 5 files changed, 390 insertions(+), 89 deletions(-) create mode 100644 Sources/SQLiteData/Internal/FetchBox.swift create mode 100644 Tests/SQLiteDataTests/FetchBoxTests.swift diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..50eb9fe6 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,32 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct Fetch: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { @@ -93,6 +113,7 @@ public struct Fetch: Sendable { database: (any DatabaseReader)? = nil ) { sharedReader = SharedReader(wrappedValue: wrappedValue, .fetch(request, database: database)) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given request. @@ -110,6 +131,19 @@ public struct Fetch: Sendable { try await sharedReader.load(.fetch(request, database: database)) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension Fetch { @@ -132,6 +166,7 @@ extension Fetch { wrappedValue: wrappedValue, .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given request. @@ -169,7 +204,11 @@ extension Fetch: Equatable where Value: Equatable { #if canImport(SwiftUI) extension Fetch: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@Fetch") + } + persisted.subscribe(generation: generation) } /// Initializes this property with a request associated with the wrapped value. @@ -192,6 +231,7 @@ extension Fetch: Equatable where Value: Equatable { wrappedValue: wrappedValue, .fetch(request, database: database, animation: animation) ) + setFetchKeyID(for: request, database: database, scheduler: .animation(animation)) } /// Replaces the wrapped value with data from the given request. diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..43e8c5f2 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing public import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,32 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct FetchAll: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader<[Element]> { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox<[Element]> + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -139,13 +159,12 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -164,13 +183,12 @@ public struct FetchAll: Sendable { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -219,6 +237,19 @@ public struct FetchAll: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchAll { @@ -294,14 +325,12 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -323,14 +352,12 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -403,7 +430,11 @@ extension FetchAll: Equatable where Element: Equatable { #if canImport(SwiftUI) extension FetchAll: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@FetchAll") + } + persisted.subscribe(generation: generation) } @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799d..8b11592a 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,32 @@ public import StructuredQueriesCore @dynamicMemberLookup @propertyWrapper public struct FetchOne: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { @@ -106,10 +127,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query that fetches the first row from a table. @@ -128,10 +151,12 @@ public struct FetchOne: Sendable { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -170,10 +195,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -191,10 +218,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -213,10 +242,12 @@ public struct FetchOne: Sendable { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -238,10 +269,12 @@ public struct FetchOne: Sendable { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -262,13 +295,12 @@ public struct FetchOne: Sendable { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -288,10 +320,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -428,6 +462,19 @@ public struct FetchOne: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchOne { @@ -475,14 +522,12 @@ extension FetchOne { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query that fetches the first row from a table. @@ -504,14 +549,12 @@ extension FetchOne { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -556,14 +599,12 @@ extension FetchOne { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -584,14 +625,12 @@ extension FetchOne { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -613,14 +652,12 @@ extension FetchOne { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -645,14 +682,12 @@ extension FetchOne { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -676,14 +711,12 @@ extension FetchOne { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -706,14 +739,12 @@ extension FetchOne { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -905,7 +936,11 @@ extension FetchOne: Equatable where Value: Equatable { #if canImport(SwiftUI) extension FetchOne: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@FetchOne") + } + persisted.subscribe(generation: generation) } @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift new file mode 100644 index 00000000..e2f3d54b --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,96 @@ +#if canImport(SwiftUI) + import Combine + import Foundation + import IssueReporting + import Sharing + import SwiftUI + + final class FetchBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: Storage + + init(sharedReader: SharedReader) { + storage = Storage(sharedReader: sharedReader) + } + + var sharedReader: SharedReader { + get { lock.withLock { storage.sharedReader } } + set { lock.withLock { storage.sharedReader = newValue } } + } + + var fetchKeyID: FetchKeyID? { + get { lock.withLock { storage.fetchKeyID } } + set { lock.withLock { storage.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 + } + guard !hasReported else { return } + reportIssue( + """ + A '\(propertyName)' property was re-initialized without a query, but was previously \ + initialized with one. This re-initialization is ignored, and the property continues \ + to observe its current query. + + To change the query associated with this property, invoke 'load' on its projected \ + value, or reset the enclosing view's identity with 'View.id'. + """ + ) + #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 + } + } + + 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 } + } + + private struct Storage { + var sharedReader: SharedReader + var fetchKeyID: FetchKeyID? + var initialValue: Value + var swiftUICancellable: AnyCancellable? + #if DEBUG + var hasReportedIgnoredReinitialization = false + #endif + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + self.initialValue = sharedReader.wrappedValue + } + } + } + + private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { + func open(_ lhs: U) -> Bool { + lhs == rhs as? U + } + guard let lhs = lhs as? any Equatable else { return nil } + return open(lhs) + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 00000000..46853993 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -0,0 +1,99 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import Testing + + @testable import SQLiteData + + @Suite struct FetchBoxTests { + let database: any DatabaseReader + + init() throws { + database = try DatabaseQueue() + } + + @Test func keyedReinitializationWithNewQueryIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keyedReinitializationWithSameQueryIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithSameDefaultIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithDifferentDefaultIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 2) + } + + @Test func keylessAdoptionCarriesSeedForward() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.sharedReader = SharedReader(value: 99) + let refresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: refresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 99) + } + + @Test func keylessReinitializationAfterLocalLoadIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.sharedReader = SharedReader(value: [1, 2, 3]) + let fresh = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.reconcile(from: fresh, propertyName: "@FetchAll") + #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) + } + + @Test func keylessReinitializationWithNonEquatableDefaultIsIgnored() { + struct Opaque: Sendable { + let n: Int + } + let persisted = FetchBox(sharedReader: SharedReader(value: Opaque(n: 1))) + let fresh = FetchBox(sharedReader: SharedReader(value: Opaque(n: 2))) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue.n == 1) + } + + @Test func keyedToKeylessReinitializationReportsIssue() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + withKnownIssue { + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + + private func fetchKeyID(_ request: some FetchKeyRequest) -> FetchKeyID { + FetchKey(request: request, database: database, scheduler: nil).id + } + } + + private struct TestRequest: FetchKeyRequest, Hashable { + let id: Int + func fetch(_ db: Database) throws -> Int { + id + } + } +#endif From 558526c207e4481051e803a473427e992de98083 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 7 Jul 2026 14:10:01 -0700 Subject: [PATCH 2/5] wip --- Sources/SQLiteData/FetchAll.swift | 16 +++++++++-- Sources/SQLiteData/FetchOne.swift | 32 +++++++++++++++++++--- Sources/SQLiteData/Internal/FetchBox.swift | 7 ++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 43e8c5f2..e073c42c 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -253,7 +253,13 @@ public struct FetchAll: Sendable { } extension FetchAll { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, @@ -437,7 +443,13 @@ extension FetchAll: Equatable where Element: Equatable { persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 8b11592a..07f7e4e9 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -478,7 +478,13 @@ public struct FetchOne: Sendable { } extension FetchOne { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -491,7 +497,13 @@ extension FetchOne { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, @@ -943,7 +955,13 @@ extension FetchOne: Equatable where Value: Equatable { persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -956,7 +974,13 @@ extension FetchOne: Equatable where Value: Equatable { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index e2f3d54b..9170223f 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -40,11 +40,8 @@ reportIssue( """ A '\(propertyName)' property was re-initialized without a query, but was previously \ - initialized with one. This re-initialization is ignored, and the property continues \ - to observe its current query. - - To change the query associated with this property, invoke 'load' on its projected \ - value, or reset the enclosing view's identity with 'View.id'. + initialized with one; this re-initialization will be ignored, and the property \ + will continue to observe the existing query """ ) #endif From ca2aa34ff90764431cf8c303b10dd20b6cfd4e5e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 15:03:42 -0700 Subject: [PATCH 3/5] wip --- Tests/SQLiteDataTests/FetchBoxTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift index 46853993..26a9f0c9 100644 --- a/Tests/SQLiteDataTests/FetchBoxTests.swift +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -77,7 +77,7 @@ let persisted = FetchBox(sharedReader: SharedReader(value: 1)) persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 1)) - withKnownIssue { + withKnownIssue(isIntermittent: true) { persisted.reconcile(from: fresh, propertyName: "@Fetch") } #expect(persisted.sharedReader.wrappedValue == 1) From d835b4ece643881b02a9dc065f5b5f80f6395ff8 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 15 Jul 2026 15:35:53 -0700 Subject: [PATCH 4/5] cleanup --- Package.resolved | 28 +++++++++++----------- Sources/SQLiteData/Internal/FetchBox.swift | 22 ++--------------- Tests/SQLiteDataTests/FetchBoxTests.swift | 28 ++++------------------ 3 files changed, 21 insertions(+), 57 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2c8e5093..d4587278 100644 --- a/Package.resolved +++ b/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", - "version" : "7.11.0" + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", - "version" : "1.6.0" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", - "version" : "1.13.0" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "e47a2f545bafa3c0c702600f3e6ce02b3d566b6f", - "version" : "2.8.2" + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", - "version" : "1.19.2" + "revision" : "1bc16f430d8410e7f087d4c787767b26fd32fe30", + "version" : "1.19.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "50a429884d7a6c0613df2a31a24009cc436bfb18", - "version" : "0.33.2" + "revision" : "9c2935e47b0ed9627e4278c566e0ac386be5472e", + "version" : "0.33.3" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", - "version" : "1.10.0" + "revision" : "8f6abcf4c8950e2679d5b2fee4ca284fd7c34886", + "version" : "1.11.0" } } ], diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index 9170223f..7203efc7 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -25,12 +25,11 @@ 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 { + if freshFetchKeyID != fetchKeyID { update(from: freshSnapshot) } - } else if snapshot.fetchKeyID != nil { + } else if fetchKeyID != nil { #if DEBUG let hasReported = lock.withLock { defer { storage.hasReportedIgnoredReinitialization = true } @@ -45,8 +44,6 @@ """ ) #endif - } else if isEqual(freshSnapshot.initialValue, snapshot.initialValue) == false { - update(from: freshSnapshot) } } @@ -54,7 +51,6 @@ lock.withLock { storage.sharedReader = other.sharedReader storage.fetchKeyID = other.fetchKeyID - storage.initialValue = other.initialValue } } @@ -70,24 +66,10 @@ private struct Storage { var sharedReader: SharedReader var fetchKeyID: FetchKeyID? - var initialValue: Value var swiftUICancellable: AnyCancellable? #if DEBUG var hasReportedIgnoredReinitialization = false #endif - - init(sharedReader: SharedReader) { - self.sharedReader = sharedReader - self.initialValue = sharedReader.wrappedValue - } - } - } - - private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { - func open(_ lhs: U) -> Bool { - lhs == rhs as? U } - guard let lhs = lhs as? any Equatable else { return nil } - return open(lhs) } #endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift index 26a9f0c9..54bcdbac 100644 --- a/Tests/SQLiteDataTests/FetchBoxTests.swift +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -31,28 +31,20 @@ #expect(persisted.sharedReader.wrappedValue == 1) } - @Test func keylessReinitializationWithSameDefaultIsIgnored() { + @Test func keylessReinitializationIsIgnored() { let persisted = FetchBox(sharedReader: SharedReader(value: 1)) - let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) persisted.reconcile(from: fresh, propertyName: "@Fetch") #expect(persisted.sharedReader.wrappedValue == 1) } - @Test func keylessReinitializationWithDifferentDefaultIsAdopted() { + @Test func keylessToKeyedReinitializationIsAdopted() { let persisted = FetchBox(sharedReader: SharedReader(value: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) persisted.reconcile(from: fresh, propertyName: "@Fetch") #expect(persisted.sharedReader.wrappedValue == 2) - } - - @Test func keylessAdoptionCarriesSeedForward() { - let persisted = FetchBox(sharedReader: SharedReader(value: 1)) - let fresh = FetchBox(sharedReader: SharedReader(value: 2)) - persisted.reconcile(from: fresh, propertyName: "@Fetch") - persisted.sharedReader = SharedReader(value: 99) - let refresh = FetchBox(sharedReader: SharedReader(value: 2)) - persisted.reconcile(from: refresh, propertyName: "@Fetch") - #expect(persisted.sharedReader.wrappedValue == 99) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) } @Test func keylessReinitializationAfterLocalLoadIsIgnored() { @@ -63,16 +55,6 @@ #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) } - @Test func keylessReinitializationWithNonEquatableDefaultIsIgnored() { - struct Opaque: Sendable { - let n: Int - } - let persisted = FetchBox(sharedReader: SharedReader(value: Opaque(n: 1))) - let fresh = FetchBox(sharedReader: SharedReader(value: Opaque(n: 2))) - persisted.reconcile(from: fresh, propertyName: "@Fetch") - #expect(persisted.sharedReader.wrappedValue.n == 1) - } - @Test func keyedToKeylessReinitializationReportsIssue() { let persisted = FetchBox(sharedReader: SharedReader(value: 1)) persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) From 2a1ac2242040f8a8f8cb2704c0cc1ec5605b430e Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:09:00 -0500 Subject: [PATCH 5/5] Remove ConcurrencyExtras (#498) * Use LockIsolated in FetchBox * fix * fix * Bump CI Xcode * Fix? * wip * Revert library test CI runner change * Smaller bump * fix --------- Co-authored-by: Stephen Celis --- .github/workflows/ci.yml | 4 +- Examples/Examples.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 42 ++++++------- 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/Fetch.swift | 2 +- Sources/SQLiteData/FetchAll.swift | 2 +- Sources/SQLiteData/FetchOne.swift | 2 +- Sources/SQLiteData/FetchSubscription.swift | 18 +++--- Sources/SQLiteData/Internal/FetchBox.swift | 62 ++++++------------- 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 - Tests/SQLiteDataTests/FetchBoxTests.swift | 31 +++++----- .../FetchSubscriptionTests.swift | 6 +- .../Internal/BaseCloudKitTests.swift | 11 ++-- .../Internal/CloudKit+CustomDump.swift | 15 ++--- .../Internal/CloudKitTestHelpers.swift | 11 ++-- 39 files changed, 177 insertions(+), 222 deletions(-) create mode 100644 Sources/SQLiteData/Internal/LockIsolated.swift 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 diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0040127d..5dc5bdb5 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1100,6 +1100,7 @@ isa = XCLocalSwiftPackageReference; relativePath = ..; traits = ( + CasePaths, LazyInitializableByDefault, ); }; @@ -1121,9 +1122,6 @@ kind = upToNextMajorVersion; minimumVersion = 1.7.0; }; - traits = ( - Clocks, - ); }; DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { isa = XCRemoteSwiftPackageReference; @@ -1140,8 +1138,6 @@ kind = upToNextMajorVersion; minimumVersion = 2.2.3; }; - traits = ( - ); }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce3dfb78..d2dfd2ae 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c56e7b70de4fe8bcc798354797a8405c0eeb2e9bc68bc0f202c14a8b11a0a97f", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", - "version" : "7.11.0" + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "206cbce3882b4de9aee19ce62ac5b7306cadd45b", - "version" : "1.7.3" + "revision" : "1197e80bc7e4b177051b6869ef93d8ac3ad677da", + "version" : "1.8.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", - "version" : "1.6.0" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "16f7dd14ee28d04617090f2a73198b8b316ffa12", - "version" : "1.13.1" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" + "revision" : "fad75807c596fecd724b0fc81cd61c94008faad4", + "version" : "2.10.3" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "25ac73741c3436605d61eceb5207e896973918e7", - "version" : "2.0.10" + "revision" : "de219a1cf34e958134e75a9ebb134cf09bf52fc6", + "version" : "2.0.11" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "e47a2f545bafa3c0c702600f3e6ce02b3d566b6f", - "version" : "2.8.2" + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { - "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", - "version" : "1.19.2" + "revision" : "1bc16f430d8410e7f087d4c787767b26fd32fe30", + "version" : "1.19.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "50a429884d7a6c0613df2a31a24009cc436bfb18", - "version" : "0.33.2" + "revision" : "e61b3713460507ce93ed4ca4d8f9e4423bf342ca", + "version" : "0.33.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", - "version" : "1.10.0" + "revision" : "8f6abcf4c8950e2679d5b2fee4ca284fd7c34886", + "version" : "1.11.0" } } ], diff --git a/Package.swift b/Package.swift index eb33cad5..317db099 100644 --- a/Package.swift +++ b/Package.swift @@ -68,7 +68,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"), @@ -87,7 +86,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/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 50eb9fe6..2a7c5516 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -206,7 +206,7 @@ extension Fetch: Equatable where Value: Equatable { public func update() { let persisted = state.wrappedValue if persisted !== box { - persisted.reconcile(from: box, propertyName: "@Fetch") + persisted.update(from: box) } persisted.subscribe(generation: generation) } diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index e073c42c..e2a9fd3f 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -438,7 +438,7 @@ extension FetchAll: Equatable where Element: Equatable { public func update() { let persisted = state.wrappedValue if persisted !== box { - persisted.reconcile(from: box, propertyName: "@FetchAll") + persisted.update(from: box) } persisted.subscribe(generation: generation) } diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 07f7e4e9..39f3ba88 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -950,7 +950,7 @@ extension FetchOne: Equatable where Value: Equatable { public func update() { let persisted = state.wrappedValue if persisted !== box { - persisted.reconcile(from: box, propertyName: "@FetchOne") + persisted.update(from: box) } persisted.subscribe(generation: generation) } diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index de794216..00644b50 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -1,4 +1,3 @@ -import ConcurrencyExtras import Perception import Sharing @@ -15,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) { @@ -28,20 +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 { - try await Task.never() - } onCancel: { - onCancel() - } + let never = AsyncStream { continuation in + cancellable.withLock { $0 = continuation } } - cancellable.withValue { $0 = task } - try await task.cancellableValue + for await _ in never {} + onCancel() + throw CancellationError() } } /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. public func cancel() { - cancellable.value?.cancel() + cancellable.withLock { $0?.finish() } } } diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index 7203efc7..7368147d 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -1,75 +1,51 @@ #if canImport(SwiftUI) import Combine import Foundation - import IssueReporting import Sharing import SwiftUI final class FetchBox: @unchecked Sendable { - private let lock = NSLock() - private var storage: Storage + 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(\.fetchKeyID) } + set { storage.withLock { $0.fetchKeyID = newValue } } } - func reconcile(from fresh: FetchBox, propertyName: String) { - let freshSnapshot = fresh.lock.withLock { fresh.storage } - if let freshFetchKeyID = freshSnapshot.fetchKeyID { - if freshFetchKeyID != fetchKeyID { - update(from: freshSnapshot) - } - } else if fetchKeyID != nil { - #if DEBUG - let hasReported = lock.withLock { - defer { storage.hasReportedIgnoredReinitialization = true } - return storage.hasReportedIgnoredReinitialization - } - 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 - } - } - - private func update(from other: Storage) { - lock.withLock { - storage.sharedReader = other.sharedReader - storage.fetchKeyID = other.fetchKeyID + func update(from other: FetchBox) { + guard + let otherFetchKeyID = other.storage.withLock(\.fetchKeyID), + otherFetchKeyID != fetchKeyID + else { return } + storage.withLock { + $0.sharedReader = other.sharedReader + $0.fetchKeyID = other.fetchKeyID } } 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 = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + } } private struct Storage { var sharedReader: SharedReader var fetchKeyID: FetchKeyID? var swiftUICancellable: AnyCancellable? - #if DEBUG - var hasReportedIgnoredReinitialization = false - #endif } } #endif 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/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift index 54bcdbac..c4016cfd 100644 --- a/Tests/SQLiteDataTests/FetchBoxTests.swift +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -17,7 +17,7 @@ persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 2)) fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) - persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.update(from: fresh) #expect(persisted.sharedReader.wrappedValue == 2) #expect(persisted.fetchKeyID == fresh.fetchKeyID) } @@ -27,14 +27,23 @@ persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 2)) fresh.fetchKeyID = fetchKeyID(TestRequest(id: 1)) - persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.update(from: fresh) #expect(persisted.sharedReader.wrappedValue == 1) } + @Test func keyedToKeylessReinitializationIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + } + @Test func keylessReinitializationIsIgnored() { let persisted = FetchBox(sharedReader: SharedReader(value: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 2)) - persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.update(from: fresh) #expect(persisted.sharedReader.wrappedValue == 1) } @@ -42,7 +51,7 @@ let persisted = FetchBox(sharedReader: SharedReader(value: 1)) let fresh = FetchBox(sharedReader: SharedReader(value: 2)) fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) - persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.update(from: fresh) #expect(persisted.sharedReader.wrappedValue == 2) #expect(persisted.fetchKeyID == fresh.fetchKeyID) } @@ -51,22 +60,10 @@ let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) persisted.sharedReader = SharedReader(value: [1, 2, 3]) let fresh = FetchBox(sharedReader: SharedReader(value: [Int]())) - persisted.reconcile(from: fresh, propertyName: "@FetchAll") + persisted.update(from: fresh) #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) } - @Test func keyedToKeylessReinitializationReportsIssue() { - let persisted = FetchBox(sharedReader: SharedReader(value: 1)) - persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) - let fresh = FetchBox(sharedReader: SharedReader(value: 1)) - withKnownIssue(isIntermittent: true) { - persisted.reconcile(from: fresh, propertyName: "@Fetch") - } - #expect(persisted.sharedReader.wrappedValue == 1) - #expect(persisted.fetchKeyID != nil) - persisted.reconcile(from: fresh, propertyName: "@Fetch") - } - private func fetchKeyID(_ request: some FetchKeyRequest) -> FetchKeyID { FetchKey(request: request, database: database, scheduler: nil).id } 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),