Module: Rooibos::Command
- Defined in:
- lib/rooibos/command.rb,
lib/rooibos/command/all.rb,
lib/rooibos/command/http.rb,
lib/rooibos/command/open.rb,
lib/rooibos/command/wait.rb,
lib/rooibos/command/batch.rb,
lib/rooibos/command/clock.rb,
lib/rooibos/command/timed.rb,
lib/rooibos/command/bubble.rb,
lib/rooibos/command/custom.rb,
lib/rooibos/command/outlet.rb,
lib/rooibos/command/random.rb,
lib/rooibos/command/deliver.rb,
lib/rooibos/command/lifecycle.rb
Overview
Commands represent side effects.
The MVU pattern separates logic from effects. Your update function returns a pure model transformation. Side effects go in commands. The runtime executes them.
Commands produce messages, not callbacks. The tag argument names the message so your update function can pattern-match on it. This keeps all logic in update and ensures messages are Ractor-shareable.
Examples
# Terminate the application
[model, Command.exit]
# Run a shell command; produces [:got_files, {stdout:, stderr:, status:}]
[model, Command.system("ls -la", :got_files)]
# No side effect
[model, nil]
Defined Under Namespace
Modules: Custom Classes: All, Batch, Bubble, Cancel, Clock, Deliver, Exit, Http, Lifecycle, Mapped, Open, Outlet, Random, System, Wait
Constant Summary collapse
- HttpResponse =
Alias to Message::HttpResponse for backwards compatibility. New code should use Rooibos::Message::HttpResponse.
Message::HttpResponse
Class Method Summary collapse
-
.all(envelope) ⇒ Object
Creates an aggregating parallel command.
-
.batch ⇒ Object
Creates a parallel batch command.
-
.bubble(message) ⇒ Object
Bubbles a message outward through the fragment hierarchy.
-
.cancel(handle) ⇒ Object
Request cancellation of a running command.
-
.clock(seconds, envelope) ⇒ Object
Creates a wall-clock time command.
-
.custom(callable = nil, grace_period: nil, &block) ⇒ Object
Gives a callable unique identity for cancellation.
-
.deliver(message) ⇒ Object
Delivers a message to Update.
-
.exit ⇒ Object
Creates a quit command.
-
.http ⇒ Object
Creates an HTTP request command.
-
.map(inner_command, mapper = nil, &block) ⇒ Object
Creates a mapped command for Fractal Architecture composition.
-
.open(path, envelope = path) ⇒ Object
Opens a file or URL with the system’s default application.
-
.random(*args) ⇒ Object
Creates a random value command.
-
.system(command, envelope, stream: false) ⇒ Object
Creates a shell execution command.
-
.uncancellable ⇒ Object
Creates a fresh cancellation that never fires.
-
.wait(seconds, envelope) ⇒ Object
Creates a one-shot timer command.
Class Method Details
.all(envelope) ⇒ Object
Creates an aggregating parallel command.
Applications load dashboards that combine user, settings, and stats. Fire-and-forget loses correlation. This command waits for all children and returns their results together in a single message.
- commands
-
One or more commands to run in parallel. Pass multiple
arguments or a single array.
Example
# Variadic syntax
Command.all(
Command.http(:get, "/users", :_),
Command.http(:get, "/stats", :_),
)
# Produces: [:all, [user_result, stats_result]]
673 674 675 |
# File 'lib/rooibos/command.rb', line 673 def self.all(envelope, *) All.new(envelope, *) end |
.batch ⇒ Object
Creates a parallel batch command.
Applications fetch data from multiple sources. Dashboard panels load users, stats, and notifications. Waiting sequentially is slow. Managing threads and error handling manually is error-prone.
This command runs children in parallel. Each child sends its own messages independently. The batch completes when all children finish or when cancellation fires.
Use it for parallel fetches, concurrent refreshes, or any work that does not need coordinated results.
- commands
-
One or more commands to run in parallel. Pass multiple
arguments or a single array.
Example
# Variadic syntax
Command.batch(
Command.http(:get, "/users", :users),
Command.http(:get, "/stats", :stats),
)
# Array syntax
Command.batch([cmd1, cmd2, cmd3])
652 653 654 |
# File 'lib/rooibos/command.rb', line 652 def self.batch(*) Batch.new(*) end |
.bubble(message) ⇒ Object
Bubbles a message outward through the fragment hierarchy.
Nested fragments produce results. Sometimes those results belong to an outer fragment. Passing callbacks or references inward couples fragments tightly. The hierarchy becomes rigid.
This command wraps a message for outward propagation. Outer fragments intercept the bubble and decide how to handle it. With the Router DSL, use observe or intercept. Without the Router, check for Command::Bubble manually and extract the message.
Use it for notifications, validation results, or any signal that flows from nested fragments to outer containers.
Example (Router DSL)
# Nested fragment signals completion
class TaskComplete < Data.define(:envelope, :task_id)
include Rooibos::Message::Predicates
end
# Return from nested Update
[model, Command.bubble(TaskComplete.new(envelope: :task, task_id: 42))]
# Outer Router observes the bubble
observe TaskComplete do |model, |
model.with(completed_tasks: model.completed_tasks + [.task_id])
end
Example (Manual Bubbling)
# Outer Update handles bubbles without Router
def self.handle_nested_result(cmd, model)
return [model, nil] unless cmd.is_a?(Command::Bubble)
case cmd.
when TaskComplete
[model.with(completed_tasks: model.completed_tasks + [cmd..task_id]), nil]
else
[model, cmd] # Re-bubble outward
end
end
189 190 191 |
# File 'lib/rooibos/command.rb', line 189 def self.bubble() Bubble.new(message:) end |
.cancel(handle) ⇒ Object
Request cancellation of a running command.
The model stores the command handle (the command object itself). Returning Command.cancel(handle) signals the runtime to stop it.
- handle
-
The command object to cancel.
Example
# Dispatch and store handle
cmd = FetchData.new(url)
[model.with(active_fetch: cmd), cmd]
# User clicks cancel
when :cancel_clicked
[model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
256 257 258 |
# File 'lib/rooibos/command.rb', line 256 def self.cancel(handle) Cancel.new(handle:) end |
.clock(seconds, envelope) ⇒ Object
Creates a wall-clock time command.
Waits for seconds then sends Message::Clock with the current time. Use for displaying time, throttling refreshes, or scheduling.
- seconds
-
Duration to wait (Float or Integer).
- envelope
-
Symbol to tag the result message.
607 608 609 |
# File 'lib/rooibos/command.rb', line 607 def self.clock(seconds, envelope) Clock.new(seconds:, envelope:) end |
.custom(callable = nil, grace_period: nil, &block) ⇒ Object
Gives a callable unique identity for cancellation.
Reusable procs and lambdas share identity. Dispatch them twice, and Command.cancel would cancel both. Wrap them to get distinct handles.
The callable must be Ractor-shareable (cannot capture mutable state). Create resources like database connections inside the callable, not in the closure. See the Custom Commands guide for details.
- callable
-
Proc, lambda, or any object responding to call(out, token). If omitted, the block is used.
- grace_period
-
Cleanup time override. Default: 2.0 seconds.
Example
# With callable
cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
# With block
cmd = Command.custom(grace_period: 5.0) do |out, token|
until token.canceled?
out.put(:tick, Time.now)
sleep 1
end
end
562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/rooibos/command.rb', line 562 def self.custom(callable = nil, grace_period: nil, &block) c = callable || block # Debug mode: validate that callable can be made shareable (fail fast) if RatatuiRuby::Debug.enabled? begin c = Ractor.make_shareable(c) rescue Ractor::IsolationError raise Rooibos::Error::Invariant, "Command.custom requires a Ractor-shareable callable. " \ "#{c.class} is not shareable. Use Ractor.make_shareable or define at top-level." end end # Production mode: skip validation (Ractors not yet used, avoid overhead) Wrapped.new(callable: c, grace_period:) end |
.deliver(message) ⇒ Object
Delivers a message to Update.
Custom commands produce results. Those results feed back into your update function. This factory method wraps a message in a command that delivers it when executed.
Example
# Define a message type
class FetchComplete < Data.define(:envelope, :data)
include Rooibos::Message::Predicates
end
# Send after a synchronous operation
result = fetch_data_sync()
[model, Command.deliver(FetchComplete.new(envelope: :items, data: result))]
# Receive in Update
in { type: :fetch_complete, envelope: :items, data: }
model.with(items: data)
143 144 145 |
# File 'lib/rooibos/command.rb', line 143 def self.deliver() Deliver.new(message:) end |
.exit ⇒ Object
Creates a quit command.
Returns a sentinel the runtime detects to terminate the application.
Example
def update(, model)
case
in { type: :key, code: "q" }
[model, Command.exit]
else
[model, nil]
end
end
119 120 121 |
# File 'lib/rooibos/command.rb', line 119 def self.exit Exit.new end |
.http ⇒ Object
Creates an HTTP request command. Supports DWIM arity - see Http.new for patterns.
679 680 681 |
# File 'lib/rooibos/command.rb', line 679 def self.http(*, **) Http.new(*, **) end |
.map(inner_command, mapper = nil, &block) ⇒ Object
Creates a mapped command for Fractal Architecture composition.
Wraps an inner command. When the inner command completes, the mapper block transforms the result into a parent message. This prevents monolithic update functions (the “God Reducer” anti-pattern).
- inner_command
-
The child command to wrap.
- mapper
-
Block that transforms child message to parent message.
Example
# Child returns Command.execute that produces [:got_files, {...}]
child_command = Command.system("ls", :got_files)
# Parent wraps to route as [:sidebar, :got_files, {...}]
parent_command = Command.map(child_command) { |child_result| [:sidebar, *child_result] }
527 528 529 530 531 532 533 534 535 |
# File 'lib/rooibos/command.rb', line 527 def self.map(inner_command, mapper = nil, &block) if mapper && block raise ArgumentError, "Pass either a mapper callable or a block, not both" end unless mapper || block raise ArgumentError, "Pass a mapper callable or a block" end Mapped.new(inner_command:, mapper: mapper || block) end |
.open(path, envelope = path) ⇒ Object
Opens a file or URL with the system’s default application. Cross-platform: uses open on macOS, xdg-open on Linux, start on Windows.
On success (exit 0), sends Message::Open. On failure (non-zero), sends Message::Error.
Example
case
in { type: :open, envelope: path }
model.with(status: "Opened #{path}")
in { type: :error, envelope: path }
model.with(error: "Could not open #{path}")
end
698 699 700 |
# File 'lib/rooibos/command.rb', line 698 def self.open(path, envelope = path) Open.new(path:, envelope:) end |
.random(*args) ⇒ Object
Creates a random value command.
Delegates to Ruby’s Random class through the runtime. The last argument is always the envelope. Everything before it maps to Random.
Without a leading symbol, calls Random#rand. With a leading symbol, calls that method on Random.
- *args
-
Arguments to pass to
Random, followed by the envelope.
621 622 623 624 |
# File 'lib/rooibos/command.rb', line 621 def self.random(*args) envelope = args.pop Random.new(args: args.freeze, envelope:) end |
.system(command, envelope, stream: false) ⇒ Object
Creates a shell execution command.
- command
-
Shell command string to execute.
- tag
-
Symbol or class to tag the result message.
- stream
-
If
true, the runtime sends incremental stdout/stderr
messages as they arrive. If <tt>false</tt> (default), waits for
completion and sends a single message with all output.
Example (Batch Mode)
# Return this from update:
[model.with(loading: true), Command.system("ls -la", :got_files)]
# Then handle it later:
def update(, model)
case
in { type: :system, envelope: :got_files, stdout:, status: 0 }
[model.with(files: stdout.lines), nil]
in { type: :system, envelope: :got_files, stderr:, status: }
[model.with(error: stderr), nil]
end
end
Example (Streaming Mode)
# Return this from update:
[model.with(loading: true), Command.system("tail -f log.txt", :log, stream: true)]
# Then handle incremental messages:
def update(, model)
case
in { type: :system, envelope: :log, stream: :stdout, content: line }
[model.with(lines: [*model.lines, line]), nil]
in { type: :system, envelope: :log, stream: :stderr, content: line }
[model.with(errors: [*model.errors, line]), nil]
in { type: :system, envelope: :log, stream: :complete, status: }
[model.with(loading: false, exit_status: status), nil]
in { type: :system, envelope: :log, stream: :error, content: msg }
[model.with(loading: false, error: msg), nil]
end
end
450 451 452 |
# File 'lib/rooibos/command.rb', line 450 def self.system(command, envelope, stream: false) System.new(command:, envelope:, stream:) end |
.uncancellable ⇒ Object
Creates a fresh cancellation that never fires.
Some I/O operations cannot be canceled mid-execution. Ruby’s Net::HTTP blocks until completion or timeout — there is no way to interrupt it.
A shared singleton would be unsafe. If any code path accidentally resolves the origin, all commands using it become canceled.
Use it for commands that wrap non-cancellable blocking I/O.
Example
token = Command.uncancellable
HttpCommand.new(url).call(outlet, token)
207 208 209 210 |
# File 'lib/rooibos/command.rb', line 207 def self.uncancellable cancellation, _origin = Concurrent::Cancellation.new(Concurrent::Promises.resolvable_event) cancellation end |
.wait(seconds, envelope) ⇒ Object
Creates a one-shot timer command.
Waits for seconds then sends TimerResponse to the update function. Use for delayed actions like notification dismissal or debounced search.
- seconds
-
Duration to wait (Float or Integer).
- envelope
-
Symbol to tag the result message.
587 588 589 |
# File 'lib/rooibos/command.rb', line 587 def self.wait(seconds, envelope) Wait.new(seconds:, envelope:) end |