Skip to content

Commit 2eb87c4

Browse files
committed
feat: generate actor-specific RBS dispatch types
Combine registered message names with application-declared signatures. Keep strict checks opt-in and preserve dynamic Ruby dispatch and global registry names. Verify actual consumer source and packaged tooling. Closes #63
1 parent 395be30 commit 2eb87c4

12 files changed

Lines changed: 446 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add optional actor-specific RBS generation for staged schedule/transmit calls
6+
and effect callback names. Reuse application-declared operation argument types
7+
without changing Ruby dispatch, runtime validation, or global effect registries.
8+
39
## 0.14.6 - 2026-09-12
410

511
- Preserve committed turns when an Active Record after-commit callback raises.

‎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
@@ -103,6 +103,96 @@ bundle exec rake rbs
103103

104104
This follows the inline convention used by `cardmagic/classifier`.
105105

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

108198
```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
@@ -39,6 +39,10 @@ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
3939

4040
## An alarm per item, with `key:`
4141

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

‎docs/roadmap.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@
6565
gem's dependencies
6666
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
6767
RuboCop policy, and a warning-free Brakeman scan
68+
- Opt-in actor-specific RBS generation for schedule/transmit keyword arguments
69+
and effect callback names, using application declarations and checked consumer
70+
fixtures. Dynamic names remain an explicit escape hatch; complete reference
71+
typing and actor-specific RBI generation are separate work
6872
- Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
6973
Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
7074
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
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)