//// LiveView-style async assign/task compatibility model.
import gleam/list
import gleam/option.{type Option, None, Some}
/// State of one async assignment.
pub type AsyncState(value) {
Idle
PendingDisconnected
Loading(ref: Int)
Succeeded(value: value)
Failed(reason: String)
Cancelled(reason: String)
}
/// Async runtime transition errors.
pub type AsyncError {
UnknownTask(key: String)
InvalidTransition(key: String, state: String, action: String)
}
type AsyncEntry(value) {
AsyncEntry(key: String, state: AsyncState(value))
}
/// Async runtime state.
pub opaque type Runtime(value) {
Runtime(connected: Bool, next_ref: Int, entries_rev: List(AsyncEntry(value)))
}
/// Build a runtime.
pub fn new(connected: Bool) -> Runtime(value) {
Runtime(connected: connected, next_ref: 1, entries_rev: [])
}
/// Connection status.
pub fn connected(runtime: Runtime(value)) -> Bool {
runtime.connected
}
/// Start one async assignment.
///
/// - when connected: enters `Loading(ref)`
/// - when disconnected: enters `PendingDisconnected`
pub fn assign_async(
runtime: Runtime(value),
key: String,
) -> #(Runtime(value), AsyncState(value)) {
case runtime.connected {
True -> {
let ref = runtime.next_ref
let state = Loading(ref: ref)
let entries_rev = put_entry(runtime.entries_rev, key, state)
#(Runtime(..runtime, next_ref: ref + 1, entries_rev: entries_rev), state)
}
False -> {
let state = PendingDisconnected
let entries_rev = put_entry(runtime.entries_rev, key, state)
#(Runtime(..runtime, entries_rev: entries_rev), state)
}
}
}
/// Mark runtime connected and start pending async assignments.
pub fn connect(runtime: Runtime(value)) -> Runtime(value) {
let #(next_ref, entries_rev) =
start_pending(list.reverse(runtime.entries_rev), runtime.next_ref, [])
Runtime(connected: True, next_ref: next_ref, entries_rev: entries_rev)
}
/// Mark runtime disconnected.
pub fn disconnect(runtime: Runtime(value)) -> Runtime(value) {
Runtime(..runtime, connected: False)
}
/// Resolve one async assignment with a success value.
pub fn succeed(
runtime: Runtime(value),
key: String,
value: value,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
transition(runtime, key, "succeed", fn(state) {
case state {
Loading(_) -> Ok(Succeeded(value: value))
_ ->
Error(InvalidTransition(
key: key,
state: state_label(state),
action: "succeed",
))
}
})
}
/// Resolve one async assignment with a failure.
pub fn fail(
runtime: Runtime(value),
key: String,
reason: String,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
transition(runtime, key, "fail", fn(state) {
case state {
Loading(_) -> Ok(Failed(reason: reason))
_ ->
Error(InvalidTransition(
key: key,
state: state_label(state),
action: "fail",
))
}
})
}
/// Cancel one async assignment.
pub fn cancel(
runtime: Runtime(value),
key: String,
reason: String,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
transition(runtime, key, "cancel", fn(state) {
case state {
PendingDisconnected -> Ok(Cancelled(reason: reason))
Loading(_) -> Ok(Cancelled(reason: reason))
_ ->
Error(InvalidTransition(
key: key,
state: state_label(state),
action: "cancel",
))
}
})
}
/// Lookup current state for one task key.
pub fn state(
runtime: Runtime(value),
key: String,
) -> Option(AsyncState(value)) {
find_state(runtime.entries_rev, key)
}
/// Stable state label for logs and assertions.
pub fn state_label(state: AsyncState(value)) -> String {
case state {
Idle -> "idle"
PendingDisconnected -> "pending_disconnected"
Loading(_) -> "loading"
Succeeded(_) -> "succeeded"
Failed(_) -> "failed"
Cancelled(_) -> "cancelled"
}
}
/// Stable error string for assertions and logs.
pub fn error_to_string(error: AsyncError) -> String {
case error {
UnknownTask(key) -> "unknown_task:" <> key
InvalidTransition(key, state, action) ->
"invalid_transition:" <> key <> ":" <> state <> ":" <> action
}
}
fn transition(
runtime: Runtime(value),
key: String,
_action: String,
with: fn(AsyncState(value)) -> Result(AsyncState(value), AsyncError),
) -> #(Runtime(value), Result(Nil, AsyncError)) {
case update_state(runtime.entries_rev, key, with, []) {
Error(error) -> #(runtime, Error(error))
Ok(entries_rev) -> #(Runtime(..runtime, entries_rev: entries_rev), Ok(Nil))
}
}
fn update_state(
entries_rev: List(AsyncEntry(value)),
key: String,
with: fn(AsyncState(value)) -> Result(AsyncState(value), AsyncError),
seen_rev: List(AsyncEntry(value)),
) -> Result(List(AsyncEntry(value)), AsyncError) {
case entries_rev {
[] -> Error(UnknownTask(key: key))
[entry, ..rest] ->
case entry.key == key {
False -> update_state(rest, key, with, [entry, ..seen_rev])
True ->
case with(entry.state) {
Error(error) -> Error(error)
Ok(state) ->
Ok(
reverse_into(seen_rev, [
AsyncEntry(..entry, state: state),
..rest
]),
)
}
}
}
}
fn put_entry(
entries_rev: List(AsyncEntry(value)),
key: String,
state: AsyncState(value),
) -> List(AsyncEntry(value)) {
case entries_rev {
[] -> [AsyncEntry(key: key, state: state)]
[entry, ..rest] ->
case entry.key == key {
True -> [AsyncEntry(..entry, state: state), ..rest]
False -> [entry, ..put_entry(rest, key, state)]
}
}
}
fn start_pending(
entries: List(AsyncEntry(value)),
next_ref: Int,
entries_rev: List(AsyncEntry(value)),
) -> #(Int, List(AsyncEntry(value))) {
case entries {
[] -> #(next_ref, entries_rev)
[entry, ..rest] ->
case entry.state {
PendingDisconnected ->
start_pending(rest, next_ref + 1, [
AsyncEntry(..entry, state: Loading(ref: next_ref)),
..entries_rev
])
_ -> start_pending(rest, next_ref, [entry, ..entries_rev])
}
}
}
fn find_state(
entries_rev: List(AsyncEntry(value)),
key: String,
) -> Option(AsyncState(value)) {
case entries_rev {
[] -> None
[entry, ..rest] ->
case entry.key == key {
True -> Some(entry.state)
False -> find_state(rest, key)
}
}
}
fn reverse_into(left: List(a), right: List(a)) -> List(a) {
case left {
[] -> right
[entry, ..rest] -> reverse_into(rest, [entry, ..right])
}
}