Last active
November 12, 2024 13:17
-
-
Save couchdeveloper/bc6fd76c353756a2cb7f681a6d7aa27d to your computer and use it in GitHub Desktop.
Observable Transducer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
enum Mocks {} | |
extension Mocks { enum Counter {} } | |
extension Mocks.Counter { | |
enum State: Sendable { | |
case start | |
case idle(value: Int) | |
case incrementing(value: Int) | |
case decrementing(value: Int) | |
case error(Error, value: Int) | |
} | |
enum Event: Sendable { | |
case start(initial: Int = 0) | |
case requestIncrement | |
case requestDecrement | |
case incrementResult(result: Result<Int, Error>) | |
case decrementResult(result: Result<Int, Error>) | |
case cancel | |
case clear | |
} | |
typealias Env = Void | |
@Sendable | |
static func transduce(_ state: inout State, event: Event) -> Effect<Event, Env>? { | |
switch (state, event) { | |
case (.start, .start(let initialValue)): | |
state = .idle(value: initialValue) | |
return .none | |
case (.idle(let current), .requestIncrement): | |
state = .incrementing(value: current) | |
return incrementEffect() | |
case (.idle(let current), .requestDecrement): | |
state = .decrementing(value: current) | |
return decrementEffect() | |
case (.incrementing(let current), .incrementResult(let result)): | |
switch result { | |
case .success(let value): | |
state = .idle(value: current + value) | |
return .none | |
case .failure(let error): | |
state = .error(error, value: current) | |
return .none | |
} | |
case (.decrementing(let current), .decrementResult(let result)): | |
switch result { | |
case .success(let value): | |
state = .idle(value: current - value) | |
return .none | |
case .failure(let error): | |
state = .error(error, value: current) | |
return .none | |
} | |
case (.error(_, value: let value), .cancel): | |
state = .idle(value: value) | |
return .none | |
case (.decrementing(value: let value), .cancel): | |
state = .idle(value: value) | |
return .cancel(with: "request") | |
case (.incrementing(value: let value), .cancel): | |
state = .idle(value: value) | |
return .cancel(with: "request") | |
case (.idle, .clear): | |
state = .idle(value: 0) | |
return .none | |
// No Transitions, no Output: | |
case (.start, _): | |
return .none | |
case (.error, _): | |
return .none | |
case (.decrementing, .start): | |
return .none | |
case (.decrementing, .requestIncrement): | |
return .none | |
case (.decrementing, .requestDecrement): | |
return .none | |
case (.decrementing, .incrementResult): | |
return .none | |
case (.decrementing, .clear): | |
return .none | |
case (.incrementing, .start): | |
return .none | |
case (.incrementing, .requestIncrement): | |
return .none | |
case (.incrementing, .requestDecrement): | |
return .none | |
case (.incrementing, .decrementResult): | |
return .none | |
case (.incrementing, .clear): | |
return .none | |
case (.idle, .start): | |
return .none | |
case (.idle, .incrementResult): | |
return .none | |
case (.idle, .decrementResult): | |
return .none | |
case (.idle, .cancel): | |
return .none | |
} | |
} | |
} | |
extension Mocks.Counter { | |
static func incrementEffect() -> Effect<Event, Env> { | |
Effect(id: "request") { env, proxy in | |
#if true | |
proxy.send(.incrementResult(result: Result<Int, Error>.success(1))) | |
#else | |
do { | |
try await Task.sleep(for: .milliseconds(100)) | |
proxy.send(.incrementResult(result: Result<Int, Error>.success(1))) | |
} catch { | |
proxy.send(.incrementResult(result: .failure(error))) | |
} | |
#endif | |
} | |
} | |
static func decrementEffect() -> Effect<Event, Env> { | |
Effect(id: "request") { env, proxy in | |
#if true | |
proxy.send(.decrementResult(result: Result<Int, Error>.success(1))) | |
#else | |
do { | |
try await Task.sleep(for: .milliseconds(100)) | |
proxy.send(.decrementResult(result: Result<Int, Error>.success(1))) | |
} catch { | |
proxy.send(.decrementResult(result: .failure(error))) | |
} | |
#endif | |
} | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Observation | |
/// A simplistic Finite State Transducer which can spawn operations, | |
/// aka `Swift.Task`s. | |
/// | |
/// The transducer's _Output_ is an optional _ Effect_. When an effect | |
/// is returned, it invokes it and manages the task handle. When the | |
/// Transducer is destroyed, it cancels all running operations. | |
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) | |
@Observable | |
@MainActor | |
public final class ObservableTransducer<State, Event: Sendable, Env: Sendable> { | |
public private(set) var state: State | |
private let env: Env | |
private let transduce: (inout State, Event) -> Effect<Event, Env>? | |
@ObservationIgnored | |
private var tasks: Dictionary<TaskID, Task<Void, Never>> = [:] | |
@ObservationIgnored | |
private var taskIdGenerator: TaskIDGenerator = .init() | |
public init( | |
initialState: State, | |
env: Env, | |
transduce: @escaping (inout State, Event) -> Effect<Event, Env>? | |
) { | |
state = initialState | |
self.env = env | |
self.transduce = transduce | |
} | |
public init( | |
initialState: State, | |
transduce: @escaping (inout State, Event) -> Effect<Event, Env>? | |
) where Env == Void { | |
state = initialState | |
self.env = Void() | |
self.transduce = transduce | |
} | |
deinit { | |
tasks.values.forEach { $0.cancel() } | |
} | |
@ObservationIgnored | |
public lazy var proxy: Proxy = Proxy(self) | |
public func send(_ event: Event) { | |
let output = transduce(&state, event) | |
if let effect = output { | |
if let oakTask = effect.invoke(with: env, proxy: proxy) { | |
let id = oakTask.id ?? TaskID(taskIdGenerator.newId()) | |
let task = oakTask.task | |
tasks[id] = task | |
Task { | |
await task.value | |
removeTask(with: id) | |
} | |
} | |
} | |
} | |
private func cancelTask(_ id: TaskID) { | |
if let task = tasks[id] { | |
task.cancel() | |
} | |
} | |
private func removeTask(with id: TaskID) { | |
tasks.removeValue(forKey: id) | |
} | |
private func removeAllTasks() { | |
tasks.values.forEach { $0.cancel() } | |
tasks = [:] | |
} | |
public struct Proxy: TransducerProxy { | |
weak var actor: ObservableTransducer? | |
init(_ actor: ObservableTransducer) { | |
self.actor = actor | |
} | |
public func send(_ event: Event) { | |
Task { @MainActor in | |
actor?.send(event) | |
} | |
} | |
public func cancelTask<ID: Hashable & Sendable>(with id: ID) { | |
Task { @MainActor in | |
let taskId: TaskID = .init(id) | |
actor?.cancelTask(taskId) | |
} | |
} | |
} | |
} | |
/// A named function which encapsulates an operation. | |
/// | |
/// An effect will be created in the _transduce_ function of the transducer. | |
/// The transducer then immediately invokes the effect which executes | |
/// the specified operation. | |
/// | |
/// An Effect is used to access the outside world, for example calling a | |
/// network API, accesing a database and so on. | |
/// | |
/// Depending on the way the effect has been created, the transducer | |
/// may manage the underlying Swift.Task which executes the operation, | |
/// so that it can be cancelled on demand and when the transducer will | |
/// be destroyed. | |
/// | |
/// An operation can send events to the transducer via the transudcer's | |
/// proxy. For example, in order to send an HTTP response to the | |
/// transducer, the response will be materialised as an event, and then | |
/// sent to the transducer via its proxy. | |
public struct Effect<Event: Sendable, Env: Sendable>: Sendable, ~Copyable { | |
public typealias Proxy = TransducerProxy<Event> | |
private let f: @Sendable (Env, any Proxy) -> OakTask? | |
private init(f: @Sendable @escaping (Env, any Proxy) -> OakTask?) { | |
self.f = f | |
} | |
consuming func invoke<Proxy: TransducerProxy<Event>>( | |
with env: Env, | |
proxy: Proxy | |
) -> OakTask? { | |
f(env, proxy) | |
} | |
} | |
extension Effect { | |
/// Initializes an Effect with the given ID and an operation. | |
/// | |
/// When invoked, the effect creates a `Swift.Task` executing the operation. | |
/// The `id` is used by its transducer to manage the task and can be later used | |
/// in the transition function, for example to explicitly cancel the operation. | |
/// | |
/// The operation receives an environment value, which can be used to obtain | |
/// depedencies or other information. It also is given the proxy - which is the receiver | |
/// of any events emitted by the operation. | |
/// | |
/// The transducer manages this operation. It is guaranteed that the operation will | |
/// be cancelld when the transducer will be destroyed. | |
/// | |
/// - Parameters: | |
/// - id: An ID which identifies this operation. | |
/// - operation: An async function receiving the environment and the proxy | |
/// as parameter. | |
public init<ID: Hashable & Sendable>( | |
id: ID, | |
operation: @Sendable @escaping (Env, any Proxy) async -> Void | |
) { | |
self.f = { env, proxy in | |
let task = Task { | |
await operation(env, proxy) | |
} | |
return .init(id: id, task: task) | |
} | |
} | |
/// Initializes an Effect with the given operation. | |
/// | |
/// When invoked, the effect creates a `Swift.Task` executing the operation. | |
/// | |
/// The operation receives an environment value, which can be used to obtain | |
/// depedencies or other information. It also is given the proxy - which is the receiver | |
/// of any events emitted by the operation. | |
/// | |
/// The transducer manages this operation. It is guaranteed that the operation will | |
/// be cancelld when the transducer will be destroyed. | |
/// | |
/// - Parameters: | |
/// - operation: An async function receiving the environment and the proxy | |
/// as parameter. | |
public init( | |
operation: @Sendable @escaping (Env, any Proxy) async -> Void | |
) { | |
self.f = { env, proxy in | |
let task = Task { | |
await operation(env, proxy) | |
} | |
return .init(task: task) | |
} | |
} | |
/// Returns an `Effect` value which when invoked, cancels the operation with the | |
/// given ID. | |
/// | |
/// - Parameter id: The ID of the operation which should be cancelled. | |
/// - Returns: An effect. | |
public static func cancel<ID: Hashable & Sendable>( | |
with id: ID | |
) -> Effect { | |
Effect { _, proxy in | |
proxy.cancelTask(with: id) | |
return nil | |
} | |
} | |
/// Returns an `Effect` value which when invoked, sends the given event | |
/// to the proxy. | |
/// | |
/// - Parameter event: The event which should be sent to the transducer. | |
/// - Returns: An effect. | |
public static func event(_ event: Event) -> Effect { | |
Effect { _, proxy in | |
proxy.send(event) | |
return nil | |
} | |
} | |
} | |
struct OakTask { | |
init<ID: Hashable & Sendable>( | |
id: ID, | |
task: Task<Void, Never> | |
) { | |
self.id = TaskID(id) | |
self.task = task | |
} | |
init(task: Task<Void, Never>) { | |
self.id = nil | |
self.task = task | |
} | |
let id: TaskID? | |
var task: Task<Void, Never> | |
} | |
/// A type whose value acts on behalf of a Transducer. | |
/// | |
/// A value can be shared across arbitrary concurrent contexts. | |
/// A proxy's life-time is independent on its subject, the | |
/// transducer. It may also outlive its transducer, in which case | |
/// its methods become no-ops. | |
/// | |
/// Proxies enable Transducers to communicate with other | |
/// detached Transducers. | |
/// | |
/// - Important: A conforiming type must not hold a strong reference to its transducer. | |
public protocol TransducerProxy<Event>: Sendable { | |
associatedtype Event: Sendable | |
/// Sends the given event to its transducer. | |
/// | |
/// The transducers's send function must be called asynchronously in | |
/// order to ensure, that the transducer's send function cannot be | |
/// re-entered. | |
/// | |
/// - Parameter event: The event which is sent to the transducer. | |
func send(_ event: Event) | |
/// Cancels the task managed by its transducer, whose ID matches parameter `id`. | |
/// | |
/// - Parameter event: The event which is sent to the transducer. | |
func cancelTask<ID: Hashable & Sendable>(with id: ID) | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment