Skip to content

Instantly share code, notes, and snippets.

@couchdeveloper
Last active November 12, 2024 13:17
Show Gist options
  • Save couchdeveloper/bc6fd76c353756a2cb7f681a6d7aa27d to your computer and use it in GitHub Desktop.
Save couchdeveloper/bc6fd76c353756a2cb7f681a6d7aa27d to your computer and use it in GitHub Desktop.
Observable Transducer
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
}
}
}
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