Skip to content

Commit 0b92ed1

Browse files
authored
Merge pull request #66 from cardmagic/feat/actor-operation-rbs
Generate actor-specific RBS dispatch types
2 parents e00f4c1 + d29fccf commit 0b92ed1

14 files changed

Lines changed: 447 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.14.7 - 2026-09-14
44

55
- Publish RBS contracts for effect callback envelopes and Ruby error summaries.
66
Check the runtime constructors and packaged consumer signatures strictly,
77
preserving ordinary hashes, keyword callbacks, serialization, and retries.
8+
- Add optional actor-specific RBS generation for staged schedule/transmit calls
9+
and effect callback names. Reuse application-declared operation argument types
10+
without changing Ruby dispatch, runtime validation, or global effect registries.
811

912
## 0.14.6 - 2026-09-12
1013

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.14.6)
4+
solid_objects (0.14.7)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.14.6)
387+
solid_objects (0.14.7)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎docs/architecture.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,10 @@ a dead letter.
443443

444444
`emit` creates a staged effect:
445445

446+
Typed applications can generate [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures)
447+
to check callback names and staged `schedule`/`transmit` calls without changing
448+
their Ruby syntax. Effect and commit-action registry names remain independent.
449+
446450
```ruby
447451
emit(
448452
:charge_payment,

‎docs/development.md‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,96 @@ This requires no internal runtime imports. See the
123123
The gem's strict payload target checks the actual constructors; its packaged
124124
consumer test also verifies that missing keys and incorrect field types fail.
125125

126+
### Actor-specific dispatch signatures
127+
128+
Typed applications can opt into `SolidObjects::ActorSignatures` to check ordinary
129+
`schedule`, `transmit`, and effect callback names inside actor methods. Add `rbs`
130+
and `steep` to the application's development dependencies. This tool is loaded
131+
explicitly and is not required by workers or ordinary Ruby applications.
132+
133+
Declare application operation types first, including inherited operations and
134+
block-defined `message` operations. Inline RBS can generate this input, or keep
135+
handwritten declarations in a separate directory such as `sig/actors`:
136+
137+
```rbs
138+
class ChatRun < SolidObjects::Actor
139+
def recover_if_stuck: (generation: Integer) -> nil
140+
def fail_turn: (effect_id: String, arguments: Hash[String, untyped], error: Hash[String, untyped]) -> nil
141+
def start: () -> nil
142+
end
143+
```
144+
145+
After loading the application's actor classes, generate a separate output file:
146+
147+
```ruby
148+
require "solid_objects/actor_signatures"
149+
150+
Rails.application.reloader.wrap do
151+
signatures = SolidObjects::ActorSignatures.generate(
152+
actors: [ChatRun],
153+
signatures: [Rails.root.join("sig/actors").to_s]
154+
)
155+
FileUtils.mkdir_p(Rails.root.join("sig/generated"))
156+
File.write(Rails.root.join("sig/generated/solid_objects.rbs"), signatures)
157+
end
158+
```
159+
160+
Run that script with `bin/rails runner` during development or CI. Outside Rails,
161+
require the actor definitions and call `generate` directly. The generator returns
162+
a string and does not write files, execute actor operations, start workers, or run
163+
migrations. Rails boot follows the application's normal loading configuration;
164+
the explicit actor list resolves its autoloaded classes within the reloader boundary.
165+
Keep generated output out of the input signature paths, and regenerate after a
166+
message is renamed or removed. Output is deterministic; handwritten signatures
167+
remain separate.
168+
169+
Load the gem and both application signature directories in `Steepfile`:
170+
171+
```ruby
172+
target :actors do
173+
library "solid_objects"
174+
signature "sig/actors"
175+
signature "sig/generated"
176+
check "app/actors"
177+
configure_code_diagnostics(Diagnostic::Ruby.strict)
178+
end
179+
```
180+
181+
The usual Ruby code now passes Steep without a dispatcher cast:
182+
183+
```ruby
184+
schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1)
185+
transmit.recover_if_stuck(generation: 1)
186+
emit :run_model, generation: 1, on_failure: :fail_turn
187+
```
188+
189+
Misspelled operations/callbacks, queries, attributes, private methods, infrastructure
190+
methods, and incorrect keyword arguments fail the strict check. Both string and
191+
symbol callback literals work. Staging returns `nil` even when an operation's own
192+
return type differs. Effect and commit-action names remain global registry names;
193+
registry contract inference is separate work.
194+
195+
Reflection supplies message names only. Values come from declared RBS signatures;
196+
missing signatures and positional/block arguments are rejected. Block-defined
197+
messages require explicit method declarations in RBS; annotate their block-local
198+
values separately when checking the block body. Generic actor classes currently
199+
require application-owned dispatcher signatures. The generator preserves method
200+
overloads and method type parameters.
201+
202+
Deliberately dynamic names can use Ruby's explicit dynamic dispatch:
203+
204+
```ruby
205+
schedule(at: Time.now).public_send(operation_name, generation: generation)
206+
public_send(:emit, :run_model, on_failure: callback_name, generation: generation)
207+
```
208+
209+
Those calls opt out of name/argument checking and retain the existing runtime
210+
validation. Ordinary calls on the generated dispatcher have no string-name fallback.
211+
`send_to`, `Reference#async`, direct calls, and queries retain their existing
212+
signatures and runtime behavior; this generator does not provide complete reference
213+
typing or Sorbet/Tapioca actor-specific RBI generation. The corresponding TypeScript
214+
work is tracked in [solid-objects-js#46](https://github.com/cardmagic/solid-objects-js/issues/46).
215+
126216
## Formatting and security
127217

128218
```bash

‎docs/operations.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ Rails schema migrations and actor state migrations are separate concerns.
6969

7070
### Host application tooling
7171

72+
RBS/Steep applications can generate
73+
[actor-specific dispatch signatures](development.md#actor-specific-dispatch-signatures)
74+
for reminders, transmit calls, and effect callback names. This is optional development
75+
tooling; generated signatures do not change runtime dispatch. The generator does
76+
not supply equivalent Sorbet/Tapioca actor-specific types.
77+
7278
Installed engine migrations are copied as
7379
`db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
7480
`Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than

‎docs/reminders.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ For watchdogs paired with an effect's give-up callback, see
4343
[typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
4444
to retain the original argument types without repeating the error hash contract.
4545

46+
For static checking of watchdog operation names and keyword arguments, opt into
47+
[actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures).
48+
The Ruby `schedule(...).recover_if_stuck(generation: ...)` syntax stays the same.
49+
4650
Pass `key:` when an actor is waiting on several things at once. The key is your
4751
own identifier for the item, and it names that item's alarm, so each item gets
4852
one:

‎docs/roadmap.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
gem's dependencies
6868
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
6969
RuboCop policy, and a warning-free Brakeman scan
70+
- Opt-in actor-specific RBS generation for schedule/transmit keyword arguments
71+
and effect callback names, using application declarations and checked consumer
72+
fixtures. Dynamic names remain an explicit escape hatch; complete reference
73+
typing and actor-specific RBI generation are separate work
7074
- Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
7175
Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
7276
range is verified rather than assumed. The compatibility job runs SQLite only;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# rbs_inline: enabled
2+
3+
require "solid_objects"
4+
require "rbs"
5+
require "pathname"
6+
7+
module SolidObjects
8+
class ActorSignatures
9+
# @rbs (actors: Array[Class], signatures: Array[String]) -> String
10+
def self.generate(actors:, signatures:)
11+
new(signatures:).generate(actors:)
12+
end
13+
14+
# @rbs @builder: untyped
15+
16+
# @rbs (signatures: Array[String]) -> void
17+
def initialize(signatures:)
18+
loader = RBS::EnvironmentLoader.new
19+
loader.add(path: Pathname.new(File.expand_path("../../sig", __dir__)))
20+
signatures.sort.each { |path| loader.add(path: Pathname.new(path)) }
21+
environment = RBS::Environment.from_loader(loader).resolve_type_names
22+
@builder = RBS::DefinitionBuilder.new(env: environment)
23+
end
24+
25+
# @rbs (actors: Array[Class]) -> String
26+
def generate(actors:)
27+
actors.uniq.sort_by { |actor| actor.name.to_s }.map { |actor| actor_signature(actor) }.join("\n")
28+
end
29+
30+
private
31+
32+
# @rbs (untyped) -> String
33+
def actor_signature(actor)
34+
unless actor < Actor && actor.name
35+
raise ArgumentError, "actor signatures require named SolidObjects::Actor subclasses"
36+
end
37+
38+
name = RBS::TypeName.parse("::#{actor.name}")
39+
definition = @builder.build_instance(name)
40+
if definition.type_params.any?
41+
raise ArgumentError, "generic actor classes require application-owned dispatcher signatures"
42+
end
43+
messages = actor.definition.messages.keys.sort
44+
methods = messages.map do |operation|
45+
method = definition.methods[operation]
46+
unless method && method.accessibility == :public
47+
raise ArgumentError, "declare a public RBS signature for #{actor.name}##{operation}"
48+
end
49+
types = method.method_types.map { |type| staged_type(type, actor.name, operation).to_s }
50+
" def #{operation}: #{types.join("\n | ")}"
51+
end
52+
callback_names = messages.flat_map { |operation| [ operation.inspect, operation.to_s.inspect ] }
53+
callbacks = (callback_names + [ "nil" ]).join(" | ")
54+
<<~RBS
55+
class #{name}
56+
interface _SolidObjectsOperations
57+
#{methods.join("\n")}
58+
def public_send: (Symbol | String, **untyped) -> nil
59+
end
60+
61+
def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations
62+
def transmit: () -> #{name}::_SolidObjectsOperations
63+
def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), **untyped) -> nil
64+
end
65+
RBS
66+
end
67+
68+
# @rbs (untyped, String, Symbol) -> untyped
69+
def staged_type(method_type, actor_name, operation)
70+
function = method_type.type
71+
if !function.is_a?(RBS::Types::Function) || method_type.block ||
72+
function.required_positionals.any? || function.optional_positionals.any? ||
73+
function.rest_positionals || function.trailing_positionals.any?
74+
raise ArgumentError, "#{actor_name}##{operation} must declare keyword-only arguments without a block"
75+
end
76+
77+
method_type.update(type: function.update(return_type: RBS::Types::Bases::Nil.new(location: nil)))
78+
end
79+
end
80+
end

‎lib/solid_objects/version.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.14.6"
4+
VERSION = "0.14.7"
55
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated from lib/solid_objects/actor_signatures.rb with RBS::Inline
2+
3+
module SolidObjects
4+
class ActorSignatures
5+
# @rbs (actors: Array[Class], signatures: Array[String]) -> String
6+
def self.generate: (actors: Array[Class], signatures: Array[String]) -> String
7+
8+
@builder: untyped
9+
10+
# @rbs (signatures: Array[String]) -> void
11+
def initialize: (signatures: Array[String]) -> void
12+
13+
# @rbs (actors: Array[Class]) -> String
14+
def generate: (actors: Array[Class]) -> String
15+
16+
private
17+
18+
# @rbs (untyped) -> String
19+
def actor_signature: (untyped) -> String
20+
21+
# @rbs (untyped, String, Symbol) -> untyped
22+
def staged_type: (untyped, String, Symbol) -> untyped
23+
end
24+
end

0 commit comments

Comments
 (0)