rooibos 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +46 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +25 -20
- data/lib/rooibos/command/batch.rb +26 -25
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +59 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +86 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +5 -5
- data/lib/rooibos/command.rb +57 -74
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +3 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +48 -0
- data/sig/rooibos/message.rbs +60 -0
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +272 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
data/doc/concepts/async_work.md
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
-
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
-
-->
|
|
5
|
-
# Async Work
|
|
6
|
-
|
|
7
|
-
## Context
|
|
8
|
-
|
|
9
|
-
Your application does concurrent work. It fetches from multiple APIs. It reads from sockets. It processes streams. These operations overlap in time.
|
|
10
|
-
|
|
11
|
-
## Problem
|
|
12
|
-
|
|
13
|
-
Threads are hard. Exceptions in spawned threads vanish silently. The main thread never learns what happened. Your application hangs, waiting for messages that will never arrive. Debugging this is miserable.
|
|
14
|
-
|
|
15
|
-
## Solution
|
|
16
|
-
|
|
17
|
-
Rooibos handles concurrency for you. Two patterns cover nearly every case. Use them instead of raw threads.
|
|
18
|
-
|
|
19
|
-
## Pattern 1: Command Orchestration
|
|
20
|
-
|
|
21
|
-
Compose child commands instead of spawning threads. Use <tt>out.source</tt> for sequential steps. Use <tt>Command.all</tt> for parallel steps.
|
|
22
|
-
|
|
23
|
-
<!-- SPDX-SnippetBegin -->
|
|
24
|
-
<!--
|
|
25
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
26
|
-
SPDX-License-Identifier: MIT-0
|
|
27
|
-
-->
|
|
28
|
-
```ruby
|
|
29
|
-
class LoadDashboard < Data.define(:user_id, :tag)
|
|
30
|
-
include Rooibos::Command::Custom
|
|
31
|
-
|
|
32
|
-
def call(out, token)
|
|
33
|
-
# Step 1: Authenticate (sequential - we need the token first)
|
|
34
|
-
auth = out.source(Authenticate.new(user_id:, tag: :_), token)
|
|
35
|
-
return if auth.nil? || token.canceled?
|
|
36
|
-
|
|
37
|
-
# Step 2: Fetch dashboard data in parallel, waiting for all to complete
|
|
38
|
-
dashboard = out.source(
|
|
39
|
-
Command.all(:_, [
|
|
40
|
-
FetchProfile.new(token: auth[:token], tag: :profile),
|
|
41
|
-
FetchNotifications.new(token: auth[:token], tag: :notifications),
|
|
42
|
-
FetchWeather.new(tag: :weather)
|
|
43
|
-
]),
|
|
44
|
-
token
|
|
45
|
-
)
|
|
46
|
-
return if dashboard.nil? || token.canceled?
|
|
47
|
-
|
|
48
|
-
# Step 3: Send a message to the update with the dashboard data
|
|
49
|
-
out.put(tag, dashboard.results)
|
|
50
|
-
return if token.canceled?
|
|
51
|
-
|
|
52
|
-
# Step 4: Log the access (sequential - after we have data)
|
|
53
|
-
out.source(LogAccess.new(user_id:, tag: :_), token)
|
|
54
|
-
return if token.canceled?
|
|
55
|
-
|
|
56
|
-
# COMING SOON: out.source_nonblock
|
|
57
|
-
# Step 5: Watch for new data via HTTP Server-Sent Events, handling parallel streaming data
|
|
58
|
-
# 5a: Pass messages from the StreamNotifications custom command directly to LoadDashboard's out.put
|
|
59
|
-
notifications = out.source_nonblocki(
|
|
60
|
-
StreamNotifications.new(:user_id, auth[:token]),
|
|
61
|
-
token,
|
|
62
|
-
)
|
|
63
|
-
# 5b: Do work to the messages before sending them to the update function
|
|
64
|
-
deltas = out.source_nonblock(
|
|
65
|
-
StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
|
|
66
|
-
token,
|
|
67
|
-
DeltaPostProcessor.new(:user_id)
|
|
68
|
-
)
|
|
69
|
-
# 5c: block this command on both async outsourced commands finishing
|
|
70
|
-
out.last(notifications, deltas)
|
|
71
|
-
return if token.canceled?
|
|
72
|
-
|
|
73
|
-
# Step 6: Log completion
|
|
74
|
-
out.source(LogAccess.new(user_id:, tag: :_, finished: true), token)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
```
|
|
78
|
-
<!-- SPDX-SnippetEnd -->
|
|
79
|
-
|
|
80
|
-
<tt>out.source</tt> blocks until the child command finishes. Pass <tt>Command.all</tt> to run children in parallel. Exceptions propagate correctly. Cancellation stops the workflow at any point.
|
|
81
|
-
|
|
82
|
-
Use this for any multi-step workflow with dependencies between stages.
|
|
83
|
-
|
|
84
|
-
## Pattern 2: Multiplexed I/O
|
|
85
|
-
|
|
86
|
-
Read from multiple sources without threads. Ruby's <tt>IO.select</tt> waits for any of several IOs to become ready.
|
|
87
|
-
|
|
88
|
-
<!-- SPDX-SnippetBegin -->
|
|
89
|
-
<!--
|
|
90
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
91
|
-
SPDX-License-Identifier: MIT-0
|
|
92
|
-
-->
|
|
93
|
-
```ruby
|
|
94
|
-
class MultiSocketReader < Data.define(:sockets, :tag)
|
|
95
|
-
include Rooibos::Command::Custom
|
|
96
|
-
|
|
97
|
-
def call(out, token)
|
|
98
|
-
remaining = sockets.dup
|
|
99
|
-
|
|
100
|
-
until remaining.empty? || token.canceled?
|
|
101
|
-
# Wait up to 0.1s for any socket to have data
|
|
102
|
-
ready = IO.select(remaining, nil, nil, 0.1)
|
|
103
|
-
next unless ready
|
|
104
|
-
|
|
105
|
-
ready[0].each do |socket|
|
|
106
|
-
data = socket.read_nonblock(4096, exception: false)
|
|
107
|
-
case data
|
|
108
|
-
when :wait_readable
|
|
109
|
-
next
|
|
110
|
-
when nil
|
|
111
|
-
remaining.delete(socket)
|
|
112
|
-
else
|
|
113
|
-
out.put(:data, { socket: socket, chunk: data })
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
out.put(tag, :complete)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
```
|
|
122
|
-
<!-- SPDX-SnippetEnd -->
|
|
123
|
-
|
|
124
|
-
<tt>IO.select</tt> multiplexes reads across sockets, pipes, or files. One thread handles many connections. No spawned threads means no silent failures.
|
|
125
|
-
|
|
126
|
-
Use this for chat clients, log tailers, or any multi-stream scenario.
|
|
127
|
-
|
|
128
|
-
## Why Not Threads?
|
|
129
|
-
|
|
130
|
-
You might wonder: "Why can't I just spawn a thread?"
|
|
131
|
-
|
|
132
|
-
<!-- SPDX-SnippetBegin -->
|
|
133
|
-
<!--
|
|
134
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
135
|
-
SPDX-License-Identifier: MIT-0
|
|
136
|
-
-->
|
|
137
|
-
```ruby
|
|
138
|
-
# ❌ Don't do this
|
|
139
|
-
def call(out, token)
|
|
140
|
-
Thread.new do
|
|
141
|
-
data = fetch_something
|
|
142
|
-
out.put(:result, data)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
```
|
|
146
|
-
<!-- SPDX-SnippetEnd -->
|
|
147
|
-
|
|
148
|
-
This looks harmless. It hides a trap.
|
|
149
|
-
|
|
150
|
-
If <tt>fetch_something</tt> raises, the exception happens in the spawned thread. Ruby logs it. The main thread never sees it. Your <tt>call</tt> method returns. The runtime considers the command complete. But <tt>out.put</tt> never ran. Your update function waits for <tt>:result</tt> forever.
|
|
151
|
-
|
|
152
|
-
The runtime cannot protect you from this. Threads spawned inside your command escape its error handling. The framework wraps <tt>call</tt> in a rescue. It does not wrap threads you create.
|
|
153
|
-
|
|
154
|
-
Use the patterns above instead. They keep errors visible.
|
|
155
|
-
|
|
156
|
-
## Choosing the Right Pattern
|
|
157
|
-
|
|
158
|
-
| Situation | Pattern |
|
|
159
|
-
|-----------|---------|
|
|
160
|
-
| Multi-step workflows | <tt>out.source</tt> + <tt>Command.all</tt> |
|
|
161
|
-
| Read from multiple sockets/pipes | <tt>IO.select</tt> |
|
|
162
|
-
| One blocking operation | Just do it in <tt>call</tt> |
|
|
163
|
-
|
|
164
|
-
Commands already run off the main thread. You rarely need additional concurrency. When you do, these patterns handle the hard parts.
|
data/doc/concepts/commands.md
DELETED
|
@@ -1,530 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
-
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
-
-->
|
|
5
|
-
# Custom Commands
|
|
6
|
-
|
|
7
|
-
## Context
|
|
8
|
-
|
|
9
|
-
Your application needs to do work in the background. Fetch data from APIs. Query databases. Read files. Process images. These operations block. Running them on the main thread freezes the UI.
|
|
10
|
-
|
|
11
|
-
## Problem
|
|
12
|
-
|
|
13
|
-
Callbacks create race conditions. Threads scatter state across the codebase. Managing concurrency manually is error-prone. Your update function needs to stay pure and deterministic.
|
|
14
|
-
|
|
15
|
-
## Solution
|
|
16
|
-
|
|
17
|
-
Commands wrap background work in a protocol. The runtime dispatches them in threads. They send messages back to your update function. Your logic stays pure. The framework handles concurrency.
|
|
18
|
-
|
|
19
|
-
Use commands for HTTP requests, database queries, file I/O, or any asynchronous work.
|
|
20
|
-
|
|
21
|
-
## Three Patterns
|
|
22
|
-
|
|
23
|
-
### Pattern 1: Procs for One-Off Tasks
|
|
24
|
-
|
|
25
|
-
Define a lambda. Pass it to <tt>Command.custom</tt>:
|
|
26
|
-
|
|
27
|
-
<!-- SPDX-SnippetBegin -->
|
|
28
|
-
<!--
|
|
29
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
30
|
-
SPDX-License-Identifier: MIT-0
|
|
31
|
-
-->
|
|
32
|
-
```ruby
|
|
33
|
-
fetch_data = -> (out, token) {
|
|
34
|
-
response = HTTParty.get("https://api.example.com/users")
|
|
35
|
-
out.put(:users_loaded, response.parsed_response)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
[model, Command.custom(fetch_data)]
|
|
39
|
-
```
|
|
40
|
-
<!-- SPDX-SnippetEnd -->
|
|
41
|
-
|
|
42
|
-
The lambda receives two arguments:
|
|
43
|
-
|
|
44
|
-
- <tt>out</tt>: An outlet to send messages back to update
|
|
45
|
-
- <tt>token</tt>: A cancellation token to check if work should stop
|
|
46
|
-
|
|
47
|
-
### Pattern 2: Classes for Reusable Commands
|
|
48
|
-
|
|
49
|
-
Define a class. Include <tt>Command::Custom</tt>. Implement <tt>call</tt>:
|
|
50
|
-
|
|
51
|
-
<!-- SPDX-SnippetBegin -->
|
|
52
|
-
<!--
|
|
53
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
54
|
-
SPDX-License-Identifier: MIT-0
|
|
55
|
-
-->
|
|
56
|
-
```ruby
|
|
57
|
-
class FetchUsers < Data.define(:url, :tag)
|
|
58
|
-
include Rooibos::Command::Custom
|
|
59
|
-
|
|
60
|
-
def call(out, token)
|
|
61
|
-
response = HTTParty.get(url)
|
|
62
|
-
out.put(tag, response.parsed_response)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Dispatch it
|
|
67
|
-
[model, FetchUsers.new(
|
|
68
|
-
url: "https://api.example.com/users",
|
|
69
|
-
tag: :users_loaded
|
|
70
|
-
)]
|
|
71
|
-
```
|
|
72
|
-
<!-- SPDX-SnippetEnd -->
|
|
73
|
-
|
|
74
|
-
The <tt>Data.define</tt> base class makes your command immutable and thread-safe.
|
|
75
|
-
|
|
76
|
-
### Pattern 3: Composition for Multi-Step Workflows
|
|
77
|
-
|
|
78
|
-
Use <tt>out.source</tt> to run child commands synchronously. Fetch one result. Use it to compose the next command:
|
|
79
|
-
|
|
80
|
-
<!-- SPDX-SnippetBegin -->
|
|
81
|
-
<!--
|
|
82
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
83
|
-
SPDX-License-Identifier: MIT-0
|
|
84
|
-
-->
|
|
85
|
-
```ruby
|
|
86
|
-
class FetchUserWithCompany < Data.define(:user_id, :tag)
|
|
87
|
-
include Rooibos::Command::Custom
|
|
88
|
-
|
|
89
|
-
def call(out, token)
|
|
90
|
-
# Step 1: Fetch user profile
|
|
91
|
-
user_result = out.source(
|
|
92
|
-
FetchUser.new(user_id: user_id, tag: :_),
|
|
93
|
-
token,
|
|
94
|
-
timeout: 10.0
|
|
95
|
-
)
|
|
96
|
-
return if user_result.nil?
|
|
97
|
-
return if token.canceled?
|
|
98
|
-
|
|
99
|
-
# Extract company_id from user result
|
|
100
|
-
user_data = user_result.last
|
|
101
|
-
company_id = user_data[:company_id]
|
|
102
|
-
|
|
103
|
-
# Step 2: Fetch company using ID from step 1
|
|
104
|
-
company_result = out.source(
|
|
105
|
-
FetchCompany.new(company_id: company_id, tag: :_),
|
|
106
|
-
token,
|
|
107
|
-
timeout: 10.0
|
|
108
|
-
)
|
|
109
|
-
return if token.canceled?
|
|
110
|
-
|
|
111
|
-
# Combine results
|
|
112
|
-
out.put(tag, {
|
|
113
|
-
user: user_data,
|
|
114
|
-
company: company_result&.last
|
|
115
|
-
})
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
```
|
|
119
|
-
<!-- SPDX-SnippetEnd -->
|
|
120
|
-
|
|
121
|
-
The <tt>source</tt> method blocks until the child command sends a message. It returns <tt>nil</tt> if cancelled or timed out. Exceptions from children propagate to the parent.
|
|
122
|
-
|
|
123
|
-
Use this for sequential API calls, conditional fetches, or any workflow where step 2 depends on step 1's result.
|
|
124
|
-
|
|
125
|
-
## The Shareability Rule
|
|
126
|
-
|
|
127
|
-
Commands run in background threads. Ruby's Ractor system requires thread-safe objects to be "shareable." This means they cannot hold mutable state.
|
|
128
|
-
|
|
129
|
-
### For Procs: Don't Capture Locals
|
|
130
|
-
|
|
131
|
-
A proc captures variables from its surrounding scope. If those variables are mutable, the proc cannot be shared.
|
|
132
|
-
|
|
133
|
-
**❌ Wrong — Captures Mutable Variable:**
|
|
134
|
-
|
|
135
|
-
<!-- SPDX-SnippetBegin -->
|
|
136
|
-
<!--
|
|
137
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
138
|
-
SPDX-License-Identifier: MIT-0
|
|
139
|
-
-->
|
|
140
|
-
```ruby
|
|
141
|
-
conn = DatabaseConnection.new # Created outside
|
|
142
|
-
|
|
143
|
-
fetch_users = -> (out, token) {
|
|
144
|
-
users = conn.query("SELECT * FROM users") # Captures 'conn'
|
|
145
|
-
out.put(:users, users)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
Command.custom(fetch_users) # 💥 Fails - conn is not shareable
|
|
149
|
-
```
|
|
150
|
-
<!-- SPDX-SnippetEnd -->
|
|
151
|
-
|
|
152
|
-
**✅ Correct — Creates Connection Inside:**
|
|
153
|
-
|
|
154
|
-
<!-- SPDX-SnippetBegin -->
|
|
155
|
-
<!--
|
|
156
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
157
|
-
SPDX-License-Identifier: MIT-0
|
|
158
|
-
-->
|
|
159
|
-
```ruby
|
|
160
|
-
fetch_users = -> (out, token) {
|
|
161
|
-
conn = DatabaseConnection.new # Created inside
|
|
162
|
-
users = conn.query("SELECT * FROM users")
|
|
163
|
-
out.put(:users, users)
|
|
164
|
-
conn.close
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
Command.custom(fetch_users) # ✅ Works - no captured state
|
|
168
|
-
```
|
|
169
|
-
<!-- SPDX-SnippetEnd -->
|
|
170
|
-
|
|
171
|
-
The lambda now captures nothing. It creates the connection fresh on every execution.
|
|
172
|
-
|
|
173
|
-
### For Classes: Reference Constants
|
|
174
|
-
|
|
175
|
-
Instance methods don't create closures. They can reference constants without shareability issues.
|
|
176
|
-
|
|
177
|
-
**✅ Database Connection Pattern:**
|
|
178
|
-
|
|
179
|
-
<!-- SPDX-SnippetBegin -->
|
|
180
|
-
<!--
|
|
181
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
182
|
-
SPDX-License-Identifier: MIT-0
|
|
183
|
-
-->
|
|
184
|
-
```ruby
|
|
185
|
-
# Create a persistent connection or connection pool
|
|
186
|
-
DB = Sequel.connect(ENV["DATABASE_URL"])
|
|
187
|
-
|
|
188
|
-
class FetchUsers < Data.define(:tag)
|
|
189
|
-
include Rooibos::Command::Custom
|
|
190
|
-
|
|
191
|
-
def call(out, token)
|
|
192
|
-
users = DB[:users].where(active: true).all
|
|
193
|
-
out.put(tag, users)
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
```
|
|
197
|
-
<!-- SPDX-SnippetEnd -->
|
|
198
|
-
|
|
199
|
-
The <tt>FetchUsers</tt> instance only holds <tt>tag</tt> (a symbol, always shareable). The <tt>call</tt> method references the global <tt>DB</tt> constant at runtime.
|
|
200
|
-
|
|
201
|
-
**✅ Pass Configuration as Attributes:**
|
|
202
|
-
|
|
203
|
-
<!-- SPDX-SnippetBegin -->
|
|
204
|
-
<!--
|
|
205
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
206
|
-
SPDX-License-Identifier: MIT-0
|
|
207
|
-
-->
|
|
208
|
-
```ruby
|
|
209
|
-
# frozen_string_literal: true
|
|
210
|
-
|
|
211
|
-
class DatabaseQuery < Data.define(:sql, :params, :tag)
|
|
212
|
-
include Rooibos::Command::Custom
|
|
213
|
-
|
|
214
|
-
def call(out, token)
|
|
215
|
-
result = DB[sql, *params].all
|
|
216
|
-
out.put(tag, result)
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Usage
|
|
221
|
-
DatabaseQuery.new(
|
|
222
|
-
sql: "SELECT * FROM users WHERE role = ?",
|
|
223
|
-
params: ["admin"].freeze,
|
|
224
|
-
tag: :admin_users
|
|
225
|
-
)
|
|
226
|
-
```
|
|
227
|
-
<!-- SPDX-SnippetEnd -->
|
|
228
|
-
|
|
229
|
-
The query and parameters are frozen strings and symbols. The database connection is a global constant.
|
|
230
|
-
|
|
231
|
-
## What Makes Objects Shareable?
|
|
232
|
-
|
|
233
|
-
- Frozen strings
|
|
234
|
-
- Symbols
|
|
235
|
-
- Numbers
|
|
236
|
-
- <tt>true</tt>, <tt>false</tt>, <tt>nil</tt>
|
|
237
|
-
- <tt>Data.define</tt> instances with shareable attributes
|
|
238
|
-
- Constants (accessed at runtime, not captured in closures)
|
|
239
|
-
|
|
240
|
-
## What Cannot Be Shared?
|
|
241
|
-
|
|
242
|
-
- Mutable strings (<tt>String.new</tt>)
|
|
243
|
-
- Database connections
|
|
244
|
-
- File handles
|
|
245
|
-
- Test instance references (<tt>self</tt> in a test method)
|
|
246
|
-
- Any object that holds mutable state
|
|
247
|
-
|
|
248
|
-
## Debug Mode Validation
|
|
249
|
-
|
|
250
|
-
In tests, <tt>Command.custom</tt> validates shareability. It tries to make your callable shareable. If it fails, you see:
|
|
251
|
-
|
|
252
|
-
```
|
|
253
|
-
RatatuiRuby::Error::Invariant: Command.custom requires a Ractor-shareable callable.
|
|
254
|
-
Proc is not shareable. Use Ractor.make_shareable or define at top-level.
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
This means your lambda captures something mutable. Common causes:
|
|
258
|
-
|
|
259
|
-
**1. Test Instance Capture**
|
|
260
|
-
|
|
261
|
-
Defining a lambda inside a test method captures <tt>self</tt>:
|
|
262
|
-
|
|
263
|
-
<!-- SPDX-SnippetBegin -->
|
|
264
|
-
<!--
|
|
265
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
266
|
-
SPDX-License-Identifier: MIT-0
|
|
267
|
-
-->
|
|
268
|
-
```ruby
|
|
269
|
-
class MyTest < Minitest::Test
|
|
270
|
-
def test_command
|
|
271
|
-
# ❌ Lambda captures 'self' (the test instance)
|
|
272
|
-
cmd = Command.custom { |out, token| out.put(@result) }
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
```
|
|
276
|
-
<!-- SPDX-SnippetEnd -->
|
|
277
|
-
|
|
278
|
-
**Solution: Define at Class Level**
|
|
279
|
-
|
|
280
|
-
<!-- SPDX-SnippetBegin -->
|
|
281
|
-
<!--
|
|
282
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
283
|
-
SPDX-License-Identifier: MIT-0
|
|
284
|
-
-->
|
|
285
|
-
|
|
286
|
-
```ruby
|
|
287
|
-
|
|
288
|
-
class MyTest < Minitest::Test
|
|
289
|
-
# Lambda defined at class level doesn't capture instance
|
|
290
|
-
TEST_COMMAND = Command.custom(-> (out, token) {
|
|
291
|
-
Thread.current[:test_result] = :done
|
|
292
|
-
out.put(:complete)
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
def test_command
|
|
296
|
-
Rooibos.run(..., command : TEST_COMMAND)
|
|
297
|
-
assert_equal :done, Thread.current[:test_result]
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
```
|
|
301
|
-
<!-- SPDX-SnippetEnd -->
|
|
302
|
-
|
|
303
|
-
**2. Mutable Closure**
|
|
304
|
-
|
|
305
|
-
Referencing a local variable from outside the lambda:
|
|
306
|
-
|
|
307
|
-
<!-- SPDX-SnippetBegin -->
|
|
308
|
-
<!--
|
|
309
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
310
|
-
SPDX-License-Identifier: MIT-0
|
|
311
|
-
-->
|
|
312
|
-
```ruby
|
|
313
|
-
result = [] # Mutable array
|
|
314
|
-
|
|
315
|
-
cmd = Command.custom { |out, token|
|
|
316
|
-
result << "item" # ❌ Captures 'result'
|
|
317
|
-
out.put(:done)
|
|
318
|
-
}
|
|
319
|
-
```
|
|
320
|
-
<!-- SPDX-SnippetEnd -->
|
|
321
|
-
|
|
322
|
-
**Solution: Use Constants or Create at Runtime**
|
|
323
|
-
|
|
324
|
-
<!-- SPDX-SnippetBegin -->
|
|
325
|
-
<!--
|
|
326
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
327
|
-
SPDX-License-Identifier: MIT-0
|
|
328
|
-
-->
|
|
329
|
-
```ruby
|
|
330
|
-
cmd = Command.custom { |out, token|
|
|
331
|
-
result = [] # Created inside
|
|
332
|
-
result << "item"
|
|
333
|
-
out.put(:done, result)
|
|
334
|
-
}
|
|
335
|
-
```
|
|
336
|
-
<!-- SPDX-SnippetEnd -->
|
|
337
|
-
|
|
338
|
-
## Production Mode
|
|
339
|
-
|
|
340
|
-
In production (non-test environments), <tt>Command.custom</tt> skips validation. This avoids overhead since the framework doesn't yet use Ractors.
|
|
341
|
-
|
|
342
|
-
Validation only runs in debug mode. Catch bugs during development. Ship fast in production.
|
|
343
|
-
|
|
344
|
-
## Cancellation
|
|
345
|
-
|
|
346
|
-
Long-running commands should check the cancellation token:
|
|
347
|
-
|
|
348
|
-
<!-- SPDX-SnippetBegin -->
|
|
349
|
-
<!--
|
|
350
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
351
|
-
SPDX-License-Identifier: MIT-0
|
|
352
|
-
-->
|
|
353
|
-
```ruby
|
|
354
|
-
class PollAPI < Data.define(:url, :interval_seconds, :tag)
|
|
355
|
-
include Rooibos::Command::Custom
|
|
356
|
-
|
|
357
|
-
def call(out, token)
|
|
358
|
-
until token.canceled?
|
|
359
|
-
response = HTTParty.get(url)
|
|
360
|
-
out.put(tag, response.parsed_response)
|
|
361
|
-
sleep interval_seconds
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
```
|
|
366
|
-
<!-- SPDX-SnippetEnd -->
|
|
367
|
-
|
|
368
|
-
Store the command handle in your model. Cancel it when the user dismisses the view:
|
|
369
|
-
|
|
370
|
-
<!-- SPDX-SnippetBegin -->
|
|
371
|
-
<!--
|
|
372
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
373
|
-
SPDX-License-Identifier: MIT-0
|
|
374
|
-
-->
|
|
375
|
-
```ruby
|
|
376
|
-
# Start polling
|
|
377
|
-
cmd = PollAPI.new(
|
|
378
|
-
url: "https://api.example.com/status",
|
|
379
|
-
interval_seconds: 30,
|
|
380
|
-
tag: :status_update
|
|
381
|
-
)
|
|
382
|
-
[model.with(poller: cmd), cmd]
|
|
383
|
-
|
|
384
|
-
# Later, cancel it
|
|
385
|
-
[model.with(poller: nil), Command.cancel(model.poller)]
|
|
386
|
-
```
|
|
387
|
-
<!-- SPDX-SnippetEnd -->
|
|
388
|
-
|
|
389
|
-
### Grace Periods
|
|
390
|
-
|
|
391
|
-
The <tt>grace_period</tt> controls how long the runtime waits for a command to finish after cancellation at application exit. Default is 0.1 seconds. If your command has a long grace period and ignores <tt>token.canceled?</tt>, it may prevent the application from exiting. Users will not like that.
|
|
392
|
-
|
|
393
|
-
Commands that ignore <tt>token.canceled?</tt> are orphaned (left running until process exit). The runtime uses cooperative cancellation only. It will not forcibly kill threads. Ruby, however, _will_ forcibly kill threads when the process exits.
|
|
394
|
-
|
|
395
|
-
Override the grace period for commands that need more time to clean up:
|
|
396
|
-
|
|
397
|
-
<!-- SPDX-SnippetBegin -->
|
|
398
|
-
<!--
|
|
399
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
400
|
-
SPDX-License-Identifier: MIT-0
|
|
401
|
-
-->
|
|
402
|
-
```ruby
|
|
403
|
-
class WebSocketListener < Data.define(:url, :tag)
|
|
404
|
-
include Rooibos::Command::Custom
|
|
405
|
-
|
|
406
|
-
def rooibos_cancellation_grace_period
|
|
407
|
-
5.0 # Give the WS close handshake time to complete
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def call(out, token)
|
|
411
|
-
ws = connect_websocket(url)
|
|
412
|
-
|
|
413
|
-
until token.canceled?
|
|
414
|
-
message = ws.receive
|
|
415
|
-
out.put(tag, message)
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
ws.close # Cleanup
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
```
|
|
422
|
-
<!-- SPDX-SnippetEnd -->
|
|
423
|
-
|
|
424
|
-
## Common Patterns
|
|
425
|
-
|
|
426
|
-
### Background File Processing
|
|
427
|
-
|
|
428
|
-
<!-- SPDX-SnippetBegin -->
|
|
429
|
-
<!--
|
|
430
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
431
|
-
SPDX-License-Identifier: MIT-0
|
|
432
|
-
-->
|
|
433
|
-
```ruby
|
|
434
|
-
class ProcessFile < Data.define(:path, :tag)
|
|
435
|
-
include Rooibos::Command::Custom
|
|
436
|
-
|
|
437
|
-
def call(out, token)
|
|
438
|
-
lines = File.readlines(path)
|
|
439
|
-
processed = lines.map(&:strip).reject(&:empty?)
|
|
440
|
-
out.put(tag, processed)
|
|
441
|
-
end
|
|
442
|
-
end
|
|
443
|
-
```
|
|
444
|
-
<!-- SPDX-SnippetEnd -->
|
|
445
|
-
|
|
446
|
-
### Batch Operations with Progress
|
|
447
|
-
|
|
448
|
-
<!-- SPDX-SnippetBegin -->
|
|
449
|
-
<!--
|
|
450
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
451
|
-
SPDX-License-Identifier: MIT-0
|
|
452
|
-
-->
|
|
453
|
-
```ruby
|
|
454
|
-
class BatchImport < Data.define(:items, :tag)
|
|
455
|
-
include Rooibos::Command::Custom
|
|
456
|
-
|
|
457
|
-
def call(out, token)
|
|
458
|
-
items.each_with_index do |item, index|
|
|
459
|
-
return if token.canceled?
|
|
460
|
-
|
|
461
|
-
import_item(item)
|
|
462
|
-
|
|
463
|
-
# Send progress updates
|
|
464
|
-
out.put(:progress, {
|
|
465
|
-
current: index + 1,
|
|
466
|
-
total: items.size
|
|
467
|
-
})
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
out.put(tag, :complete)
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
private
|
|
474
|
-
|
|
475
|
-
def import_item(item)
|
|
476
|
-
# Your import logic
|
|
477
|
-
end
|
|
478
|
-
end
|
|
479
|
-
```
|
|
480
|
-
<!-- SPDX-SnippetEnd -->
|
|
481
|
-
|
|
482
|
-
### Database Query with Connection Pooling
|
|
483
|
-
|
|
484
|
-
<!-- SPDX-SnippetBegin -->
|
|
485
|
-
<!--
|
|
486
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
487
|
-
SPDX-License-Identifier: MIT-0
|
|
488
|
-
-->
|
|
489
|
-
```ruby
|
|
490
|
-
# Connection pool (created once at app startup)
|
|
491
|
-
DB = Sequel.connect(
|
|
492
|
-
ENV["DATABASE_URL"],
|
|
493
|
-
max_connections: 10
|
|
494
|
-
)
|
|
495
|
-
|
|
496
|
-
class FetchUserProfile < Data.define(:user_id, :tag)
|
|
497
|
-
include Rooibos::Command::Custom
|
|
498
|
-
|
|
499
|
-
def call(out, token)
|
|
500
|
-
user = DB[:users].where(id: user_id).first
|
|
501
|
-
posts = DB[:posts].where(user_id: user_id).limit(10).all
|
|
502
|
-
|
|
503
|
-
out.put(tag, { user: user, posts: posts })
|
|
504
|
-
end
|
|
505
|
-
end
|
|
506
|
-
```
|
|
507
|
-
<!-- SPDX-SnippetEnd -->
|
|
508
|
-
|
|
509
|
-
## Summary
|
|
510
|
-
|
|
511
|
-
**For one-off tasks:**
|
|
512
|
-
- Use <tt>Command.custom</tt> with lambdas
|
|
513
|
-
- Don't capture mutable variables
|
|
514
|
-
- Create resources inside the lambda
|
|
515
|
-
|
|
516
|
-
**For reusable commands:**
|
|
517
|
-
- Use <tt>Data.define</tt> classes
|
|
518
|
-
- Include <tt>Command::Custom</tt>
|
|
519
|
-
- Reference constants for database connections
|
|
520
|
-
- Pass configuration as frozen attributes
|
|
521
|
-
|
|
522
|
-
**Debug mode catches bugs:**
|
|
523
|
-
- Validates shareability in tests
|
|
524
|
-
- Provides clear error messages
|
|
525
|
-
- Skipped in production for performance
|
|
526
|
-
|
|
527
|
-
**Cancellation:**
|
|
528
|
-
- Check <tt>token.canceled?</tt> in loops
|
|
529
|
-
- Set <tt>grace_period</tt> for cleanup time
|
|
530
|
-
- Store command handles in your model
|