Skip to content

Support strict concurrency #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// swift-tools-version:5.8
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "SwiftRepo",
platforms: [
.iOS("18.0"),
.iOS("16.0"),
.macOS("15.0"),
],
products: [
Expand Down Expand Up @@ -43,5 +43,6 @@ let package = Package(
.unsafeFlags(["-enable-library-evolution"]),
]
),
]
],
swiftLanguageModes: [.version("6.0")]
)
7 changes: 4 additions & 3 deletions Sources/SwiftRepo/Loading/LoadingController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import Combine
import SwiftRepoCore

/// A state machine for loading, loaded, error and empty states.
public final actor LoadingController<DataType> where DataType: Emptyable {
public typealias SyncEmptyable = Emptyable & Sendable
public final actor LoadingController<DataType> where DataType: SyncEmptyable {

// MARK: - API

Expand All @@ -17,7 +18,7 @@ public final actor LoadingController<DataType> where DataType: Emptyable {
public private(set) lazy var state: AnyPublisher<State, Never> = stateSubject.eraseToAnyPublisher()

/// The data loading states.
public enum State: CustomStringConvertible {
public enum State: CustomStringConvertible & Sendable {
/// An initial loading state when there is no data to display. Components are responsible for displaying their own UI,
/// if they choose to do so, when updating after initial data has already been loaded. For example, a list view may display
/// a "pull-to-refresh" UI until the next state transition.
Expand Down Expand Up @@ -393,7 +394,7 @@ extension LoadingController.State: Equatable where DataType: Equatable {
}
}

private extension Result where Success: Emptyable {
private extension Result where Success: SyncEmptyable {
@MainActor
func asLoadState(
currentLoadState: LoadingController<Success>.LoadState,
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftRepo/Repository/ModelResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
/// Partnered with a `QueryRepository` using an additional model store, `Value` will be
/// propagated via an `ObservableStore` and the array of `Model`s will be placed in
/// the `ModelStore`.
@available(iOS 17, *)
public protocol ModelResponse {
/// Can be used to propagate additional metadata related to the response via an `ObservableStore`
associatedtype Value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import SwiftRepoCore
/// If the remote mutation fails and there are no pending remote mutations, then the last known valid value is restored, potentially reverting optimistic local mutations.
/// A valid value is defined as either the original value from the last idle period or the most recent success result from a remote mutation.
public final actor OptimisticMutation<MutationId, Variables, Value>: Mutation
where MutationId: Hashable, Variables: Hashable {
where MutationId: SyncHashable, Variables: SyncHashable, Value: Sendable {
// MARK: - API

public func mutate(id: MutationId, variables: Variables) async throws {
Expand All @@ -40,6 +40,7 @@ public final actor OptimisticMutation<MutationId, Variables, Value>: Mutation
}
}


public nonisolated func publisher(for id: MutationId) -> AnyPublisher<ResultType, Never> {
subject
.filter { $0.mutationId == id }
Expand Down Expand Up @@ -133,3 +134,6 @@ public final actor OptimisticMutation<MutationId, Variables, Value>: Mutation
}
}
}

/// I do not believe that PassthroughSubject is actually sendable. We may need to create an actual sendable subject.
extension PassthroughSubject: @unchecked @retroactive Sendable {}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ import Foundation
/// directly in `QueryRepository`, the compiler doesn't like it and I was getting compiler segmentation faults
/// compiling generated mocks.
public protocol HasValueResult {
associatedtype Value
associatedtype Value: Sendable
typealias ValueResult = Result<Value, Error>
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import Foundation
/// are somehow causing a segmentation fault during compilation. It seems to be explicitly due to the `typealias ResultType`.
/// For some reason, pushing `typealias ResultType` into a parent protocol fixes the segumentation fault. It also, unfortunately,
/// forces the order of the mocked generic types to be `<Variables, MutationId, Value>` rather than `<MutationId, Variables, Value>`.
public typealias SyncHashable = Hashable & Sendable
public protocol MutationBase {
/// Mutation ID identifies a unique mutation for the purposes of optimistic updating, debouncing and providing ID-scoped publishers.
associatedtype MutationId: Hashable
associatedtype MutationId: SyncHashable

/// The mutation parameters.
associatedtype Variables: Hashable
associatedtype Variables: SyncHashable

/// The type of value being mutated.
associatedtype Value
Expand Down
11 changes: 6 additions & 5 deletions Sources/SwiftRepo/Repository/Protocols/Query/Query.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ public enum QueryError: String, Error {
///
/// In a typical usage, a repository would query remote data through an
/// instance of `Query`, which would in turn be responsible for making the service call.
public protocol Query<QueryId, Variables, Value> {
public protocol Query<QueryId, Variables, Value>: Sendable {
/// Query ID identifies a unique request for the purposes of request de-duplication, cancellation and providing ID-scoped publishers.
associatedtype QueryId: Hashable
associatedtype QueryId: SyncHashable

/// The variables that provide the request parameters. When two overlapping queries are made with the same query ID and variables,
/// only one request is made. When two overlapping queries are made with the same query ID and different variables, any ongoing
/// request is cancelled and a new request is made with the latest variables.
associatedtype Variables: Hashable
associatedtype Variables: SyncHashable

/// The response type returned by the query.
associatedtype Value
associatedtype Value: Sendable

/// The result type used by publishers.
typealias ResultType = QueryResult<QueryId, Variables, Value, Error>
Expand All @@ -53,7 +53,7 @@ public protocol Query<QueryId, Variables, Value> {
}

public extension Query {
typealias WillGet = () async -> Void
typealias WillGet = @Sendable () async -> Void

@discardableResult
/// Conditionally perform the query if needed based on the specified strategy and the state of the store.
Expand Down Expand Up @@ -130,6 +130,7 @@ public extension Query {
/// - strategy: The query strategy
/// - willGet: A closure that will be called if and when the query is performed. This is typically the `LoadingController.loading` function.
/// - Returns: The value if the query was performed. Otherwise, `nil`.
@available(iOS 17, *)
func get<Store, Key, ModelStore>(
id: QueryId,
variables: Variables,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import SwiftRepoCore
/// a single, simplified interface. An example use case is account service. Specifically getting account info, which requires no input variables.
public protocol ConstantQueryRepository<Variables, Value>: HasValueResult {

associatedtype Variables: Hashable
associatedtype Variables: SyncHashable

/// Performs the query, if needed, based on the query stategy of the underlying implementation.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import SwiftRepoCore
/// 2. File repository, where the query ID is typically some unique string, such as a UUID + prefix and the variables are a potentially temporary file URL.
public protocol QueryRepository<QueryId, Variables, Key, Value>: HasValueResult {

associatedtype QueryId: Hashable
associatedtype QueryId: SyncHashable

associatedtype Variables: Hashable
associatedtype Variables: SyncHashable

associatedtype Key: Hashable
associatedtype Key: SyncHashable

/// Performs the query, if needed, based on the query stategy of the underlying implementation.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public protocol ObservableStore<Key, PublishKey, Value>: Store {
/// the key and publish key can be equivalent, e.g. the query ID, if only the most recent value needs to be stored for a given key. In most cases, `QueryStoreKey` should be
/// used because it provides the most responsive user experience. Query ID is a better choice if the variables are constant or cannot be relied upon to as a stable identifier
/// (e.g. temporary FileStack URLs).
associatedtype PublishKey: Hashable
associatedtype PublishKey: SyncHashable

/// The identifiable result type used by subscribers.
typealias StoreResultType = StoreResult<Key, Value, Error>
Expand Down Expand Up @@ -200,18 +200,20 @@ extension ObservableStore where Value: HasMutatedAt {
AnySubscriber { subscription in
subscription.request(.unlimited)
} receiveValue: { value in
Task {
do {
let key = value[keyPath: keyField]
if let currentMutatedAt = try await self.get(key: key)?.mutatedAt,
if let currentMutatedAt = try self.get(key: key)?.mutatedAt,
value.mutatedAt <= currentMutatedAt {
return
return .unlimited
}
try await self.set(key: key, value: value)
}
try self.set(key: key, value: value)
} catch {}
return .unlimited
} receiveCompletion: { _ in
}
}
}

extension ObservableStoreChange: Equatable where Value: Equatable {}

extension AnySubscriber: @unchecked @retroactive Sendable {}
11 changes: 4 additions & 7 deletions Sources/SwiftRepo/Repository/Protocols/Store/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,32 @@
import Foundation

/// An interface for in-memory and/or persistent storage of key/value pairs.
public protocol Store<Key, Value> {
@MainActor public protocol Store<Key, Value>: Sendable {
/// The type of key used by the store.
associatedtype Key: Hashable
associatedtype Key: Hashable & Sendable

/// The type of value stored.
associatedtype Value
associatedtype Value: Sendable

/// Set or remove a value from the cache.
/// - Parameters:
/// - key: the unique key
/// - value: the value to store. Pass `nil` to delete any existing value.
@MainActor
@discardableResult
func set(key: Key, value: Value?) throws -> Value?

/// Get a value from the cache.
/// - Parameter key: the unique key
/// - Returns: the current value contained in the store. Returns `nil` if there is no value.
@MainActor
func get(key: Key) throws -> Value?

/// Returns the age of the current value assigned to the given key
@MainActor
func age(of key: Key) throws -> TimeInterval?

/// Removes all values. Does not publish any changes.
nonisolated
func clear() async throws

/// Return all keys that exist in the store.
@MainActor
var keys: [Key] { get throws }
}
6 changes: 3 additions & 3 deletions Sources/SwiftRepo/Repository/Query/DefaultQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import Combine
import Foundation

/// The default `Query` implementation.
public final actor DefaultQuery<QueryId, Variables, Value>: Query where QueryId: Hashable, Variables: Hashable {
public final actor DefaultQuery<QueryId, Variables, Value>: Query where QueryId: SyncHashable, Variables: SyncHashable, Value: Sendable {
// MARK: - API

public typealias ResultType = QueryResult<QueryId, Variables, Value, Error>

/// Create a `DefaultQuery` given a remote operation.
/// - Parameters:
/// - queryOperation: a closure that performs the query operation, typically making a service call and returning the data.
public init(queryOperation: @escaping (Variables) async throws -> Value) {
public init(queryOperation: @Sendable @escaping (Variables) async throws -> Value) {
self.queryOperation = queryOperation
}

Expand Down Expand Up @@ -74,7 +74,7 @@ public final actor DefaultQuery<QueryId, Variables, Value>: Query where QueryId:

// MARK: - Variables

private let queryOperation: (Variables) async throws -> Value
private let queryOperation: @Sendable (Variables) async throws -> Value
private let subject = PassthroughSubject<ResultType, Never>()
private var taskCollateral: [QueryId: TaskCollateral] = [:]
private var lastVariables: [QueryId: Variables] = [:]
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftRepo/Repository/Query/QueryStoreKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation

/// A data model to use for storing query results by query ID and variables. This can type can be used as the store key in order to
/// maintain a cache for all variables rather than just the most recently used variable.
public struct QueryStoreKey<QueryId, Variables>: Hashable where QueryId: Hashable, Variables: Hashable {
public struct QueryStoreKey<QueryId, Variables>: SyncHashable where QueryId: SyncHashable, Variables: SyncHashable {
public let queryId: QueryId
public let variables: Variables

Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftRepo/Repository/Query/QueryStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import Foundation

/// A list of strategies for determining when stored data needs to be refreshed.
public enum QueryStrategy {
public enum QueryStrategy: Sendable {
/// A new query is performed if the stored data is older than the specified `TimeInterval`.
/// Stored data is provided initially, regardless of the age of the stored value.
case ifOlderThan(TimeInterval)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import SwiftRepoCore

/// The default `ConstantQueryRepository` implementation.
public final class DefaultConstantQueryRepository<Variables, Value>: ConstantQueryRepository
where Variables: Hashable {
where Variables: SyncHashable, Value: Sendable {
// MARK: - API

public typealias QueryType = any Query<Variables, Variables, Value>
Expand Down Expand Up @@ -43,7 +43,7 @@ public final class DefaultConstantQueryRepository<Variables, Value>: ConstantQue
variables: Variables,
observableStore: ObservableStoreType,
queryStrategy: QueryStrategy,
queryOperation: @escaping (Variables) async throws -> Value
queryOperation: @Sendable @escaping (Variables) async throws -> Value
) {
self.init(
variables: variables,
Expand All @@ -66,7 +66,7 @@ public final class DefaultConstantQueryRepository<Variables, Value>: ConstantQue
public func get(
errorIntent: ErrorIntent,
queryStrategy: QueryStrategy?,
willGet: @escaping () async -> Void
willGet: @Sendable @escaping () async -> Void
) async {
await repository.get(
queryId: variables,
Expand Down
Loading