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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +46 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_react_developers.md +17 -0
  53. data/doc/getting_started/index.md +52 -0
  54. data/doc/getting_started/install.md +20 -0
  55. data/doc/getting_started/quickstart.md +9 -45
  56. data/doc/getting_started/ruby_primer.md +19 -0
  57. data/doc/getting_started/why_rooibos.md +20 -0
  58. data/doc/index.md +79 -11
  59. data/doc/scaling_up/async_patterns.md +20 -0
  60. data/doc/scaling_up/command_composition.md +20 -0
  61. data/doc/scaling_up/custom_commands.md +21 -0
  62. data/doc/scaling_up/fractal_architecture.md +20 -0
  63. data/doc/scaling_up/index.md +30 -0
  64. data/doc/scaling_up/message_routing.md +20 -0
  65. data/doc/scaling_up/ractor_safety.md +20 -0
  66. data/doc/scaling_up/testing.md +21 -0
  67. data/doc/troubleshooting/common_errors.md +20 -0
  68. data/doc/troubleshooting/debugging.md +21 -0
  69. data/doc/troubleshooting/index.md +23 -0
  70. data/doc/troubleshooting/performance.md +20 -0
  71. data/doc/tutorial/01_project_setup.md +44 -0
  72. data/doc/tutorial/02_hello_world.md +45 -0
  73. data/doc/tutorial/03_static_file_list.md +44 -0
  74. data/doc/tutorial/04_arrow_navigation.md +47 -0
  75. data/doc/tutorial/05_real_files.md +45 -0
  76. data/doc/tutorial/06_safe_refactoring.md +21 -0
  77. data/doc/tutorial/07_red_first_tdd.md +26 -0
  78. data/doc/tutorial/08_file_metadata.md +42 -0
  79. data/doc/tutorial/09_text_preview.md +44 -0
  80. data/doc/tutorial/10_directory_tree.md +42 -0
  81. data/doc/tutorial/11_pane_focus.md +40 -0
  82. data/doc/tutorial/12_sorting.md +41 -0
  83. data/doc/tutorial/13_filtering.md +43 -0
  84. data/doc/tutorial/14_toggle_hidden.md +41 -0
  85. data/doc/tutorial/15_text_input_widget.md +43 -0
  86. data/doc/tutorial/16_rename_files.md +42 -0
  87. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  88. data/doc/tutorial/18_progress_indicators.md +43 -0
  89. data/doc/tutorial/19_atomic_operations.md +42 -0
  90. data/doc/tutorial/20_external_editor.md +42 -0
  91. data/doc/tutorial/21_modal_overlays.md +41 -0
  92. data/doc/tutorial/22_error_handling.md +43 -0
  93. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  94. data/doc/tutorial/24_mouse_events.md +43 -0
  95. data/doc/tutorial/25_resize_events.md +43 -0
  96. data/doc/tutorial/26_loading_states.md +42 -0
  97. data/doc/tutorial/27_performance.md +43 -0
  98. data/doc/tutorial/28_color_schemes.md +47 -0
  99. data/doc/tutorial/29_configuration.md +124 -0
  100. data/doc/tutorial/30_going_further.md +17 -0
  101. data/doc/tutorial/index.md +17 -0
  102. data/examples/app_file_browser/app.rb +40 -0
  103. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  104. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  105. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  106. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  107. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  108. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  112. data/examples/verify_website_first_app/app.rb +85 -0
  113. data/examples/verify_website_hello_mvu/app.rb +31 -0
  114. data/examples/widget_command_system/app.rb +15 -13
  115. data/exe/rooibos +10 -0
  116. data/generate_tutorial_stubs.rb +126 -0
  117. data/lib/rooibos/cli/commands/new.rb +373 -0
  118. data/lib/rooibos/cli/commands/run.rb +98 -0
  119. data/lib/rooibos/cli.rb +78 -0
  120. data/lib/rooibos/command/all.rb +25 -20
  121. data/lib/rooibos/command/batch.rb +26 -25
  122. data/lib/rooibos/command/custom.rb +84 -1
  123. data/lib/rooibos/command/http.rb +59 -55
  124. data/lib/rooibos/command/lifecycle.rb +5 -5
  125. data/lib/rooibos/command/open.rb +86 -0
  126. data/lib/rooibos/command/outlet.rb +105 -3
  127. data/lib/rooibos/command/wait.rb +5 -5
  128. data/lib/rooibos/command.rb +57 -74
  129. data/lib/rooibos/message/batch.rb +39 -0
  130. data/lib/rooibos/message/canceled.rb +51 -0
  131. data/lib/rooibos/message/error.rb +48 -0
  132. data/lib/rooibos/message/open.rb +30 -0
  133. data/lib/rooibos/message.rb +84 -4
  134. data/lib/rooibos/router.rb +11 -14
  135. data/lib/rooibos/runtime.rb +40 -43
  136. data/lib/rooibos/shortcuts.rb +47 -0
  137. data/lib/rooibos/test_helper.rb +71 -6
  138. data/lib/rooibos/version.rb +1 -1
  139. data/lib/rooibos/welcome.rb +237 -0
  140. data/lib/rooibos.rb +4 -3
  141. data/mise.toml +1 -1
  142. data/rbs_collection.lock.yaml +2 -2
  143. data/sig/concurrent.rbs +3 -0
  144. data/sig/gem.rbs +20 -0
  145. data/sig/rooibos/cli.rbs +42 -0
  146. data/sig/rooibos/command.rbs +48 -0
  147. data/sig/rooibos/message.rbs +60 -0
  148. data/sig/rooibos/shortcuts.rbs +14 -0
  149. data/sig/rooibos/test_helper.rbs +6 -2
  150. data/sig/rooibos/welcome.rbs +75 -0
  151. data/tasks/install.rake +29 -0
  152. data/tasks/resources/build.yml.erb +2 -0
  153. metadata +272 -38
  154. data/doc/concepts/application_architecture.md +0 -197
  155. data/doc/concepts/application_testing.md +0 -49
  156. data/doc/concepts/async_work.md +0 -164
  157. data/doc/concepts/commands.md +0 -530
  158. data/doc/concepts/message_processing.md +0 -51
  159. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  160. data/doc/contributors/WIP/implementation_plan.md +0 -409
  161. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  162. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  163. data/doc/contributors/WIP/task.md +0 -36
  164. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  165. data/doc/contributors/kit-no-outlet.md +0 -238
  166. data/doc/contributors/priorities.md +0 -38
  167. data/doc/images/.gitkeep +0 -0
  168. data/exe/.gitkeep +0 -0
  169. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -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.
@@ -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