Current section
Files
Jump to
Current section
Files
lib/skuld/effects/transaction/noop.ex
# No-op transaction handler for the Transaction effect.
#
# Provides env state rollback semantics without any actual database transaction.
# Useful for testing transactional logic or when you need env state rollback
# but have no database involvement.
#
# ## Behavior
#
# For `transact(comp)`:
# - Normal completion -> returns result, env state preserved
# - Throw/Suspend -> propagates, env state restored to pre-transaction values
# - Explicit `rollback(reason)` -> returns `{:rolled_back, reason}`, env state restored
#
# ## Example
#
# alias Skuld.Comp
# alias Skuld.Effects.Transaction
#
# comp do
# result <- Transaction.transact(comp do
# _ <- do_something()
# :ok
# end)
# result
# end
# |> Transaction.Noop.with_handler()
# |> Comp.run!()
defmodule Skuld.Effects.Transaction.Noop do
@moduledoc false
@behaviour Skuld.Comp.IHandle
@behaviour Skuld.Comp.IInstall
alias Skuld.Comp
alias Skuld.Comp.Env
alias Skuld.Comp.ISentinel
alias Skuld.Comp.Types
alias Skuld.Effects.Transaction
@state_key "Elixir.Skuld.Effects.Transaction.Noop::config"
@doc """
Install a no-op Transaction handler.
Handles `transact` operations by running the inner computation without any
actual transaction wrapping. Provides env state rollback on rollback/sentinel,
matching `Transaction.Ecto` semantics.
## Options
* `:preserve_state_on_rollback` - list of `env.state` keys whose values
should be kept from the post-transaction env even when the transaction
rolls back. See `Transaction.Ecto.with_handler/3` for details.
## Example
comp do
result <- Transaction.transact(comp do
_ <- Transaction.rollback(:test_reason)
:never_reached
end)
result
end
|> Transaction.Noop.with_handler()
|> Comp.run!()
#=> {:rolled_back, :test_reason}
"""
@spec with_handler(Types.computation(), keyword()) :: Types.computation()
def with_handler(comp, opts \\ []) do
preserve_keys = Keyword.get(opts, :preserve_state_on_rollback, [])
config = %{
preserve_state_on_rollback: preserve_keys
}
comp
|> Comp.scoped(fn env ->
previous = Env.get_state(env, @state_key)
modified = Env.put_state(env, @state_key, config)
finally_k = fn v, e ->
restored_env =
case previous do
nil -> %{e | state: Map.delete(e.state, @state_key)}
val -> Env.put_state(e, @state_key, val)
end
{v, restored_env}
end
{modified, finally_k}
end)
|> Comp.with_handler(Transaction.sig(), &__MODULE__.handle/3)
end
@doc """
Install Noop handler via catch clause syntax.
catch
Transaction.Noop -> nil
Transaction.Noop -> [preserve_state_on_rollback: [key]]
"""
@impl Skuld.Comp.IInstall
def __handle__(comp, nil), do: with_handler(comp)
def __handle__(comp, opts) when is_list(opts), do: with_handler(comp, opts)
#############################################################################
## IHandle
#############################################################################
@transact_op Transaction.transact_op()
@rollback_op Transaction.rollback_op()
@impl Skuld.Comp.IHandle
def handle({@transact_op, inner_comp}, env, k) do
config = get_config!(env)
handle_transact(inner_comp, config, env, k)
end
@impl Skuld.Comp.IHandle
def handle({@rollback_op, _reason}, _env, _k) do
raise ArgumentError, """
Transaction.rollback/1 called outside of a transaction.
rollback/1 must be called within a Transaction.transact/1 block:
comp do
result <- Transaction.transact(comp do
# ... do work ...
_ <- Transaction.rollback(:some_reason)
end)
result
end
"""
end
# Catch-all for unknown ops
def handle(op, _env, _k) do
raise ArgumentError,
"Transaction.Noop handler received an unknown operation: #{inspect(op)}"
end
#############################################################################
## Private Helpers
#############################################################################
defp get_config!(env) do
case Env.get_state(env, @state_key) do
nil ->
# Fallback for backwards compatibility if handler installed without config
%{preserve_state_on_rollback: []}
%{} = config ->
config
end
end
# Restore env state to pre-transaction values on rollback, keeping only
# the preserve_state_on_rollback keys from the post-transaction env.
defp restore_state_on_rollback(pre_tx_env, final_env, preserve_keys) do
preserved_state =
Enum.reduce(preserve_keys, %{}, fn key, acc ->
case Map.fetch(final_env.state, key) do
{:ok, val} -> Map.put(acc, key, val)
:error -> acc
end
end)
%{pre_tx_env | state: Map.merge(pre_tx_env.state, preserved_state)}
end
# Handle the transact operation — run inner comp without actual transaction
defp handle_transact(inner_comp, config, env, k) do
# Install handlers for rollback and nested transact
wrapped =
inner_comp
|> Comp.with_handler(Transaction.sig(), fn
{@rollback_op, reason}, e, _inner_k ->
# Return {:rolled_back, reason} — don't call continuation
{{:rolled_back, reason}, e}
{@transact_op, nested_comp}, e, nested_k ->
# Nested transact inherits the parent's config
handle_transact(nested_comp, config, e, nested_k)
op, e, inner_k ->
# Unknown operations — delegate to outer handler
handle(op, e, inner_k)
end)
# Run the computation
{result, final_env} = Comp.call(wrapped, env, &Comp.identity_k/2)
preserve_keys = config.preserve_state_on_rollback
cond do
ISentinel.sentinel?(result) ->
# Sentinel (Throw, Suspend, etc.) — restore pre-transaction env state
restored_env = restore_state_on_rollback(env, final_env, preserve_keys)
{result, restored_env}
match?({:rolled_back, _}, result) ->
# Explicit rollback — restore pre-transaction env state
restored_env = restore_state_on_rollback(env, final_env, preserve_keys)
k.(result, restored_env)
true ->
# Normal result — pass through with post-transaction env
k.(result, final_env)
end
end
end