Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Changelog

## Unreleased
## 0.14.7 - 2026-09-14

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

## 0.14.6 - 2026-09-12

Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.14.6)
solid_objects (0.14.7)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -384,7 +384,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.14.6)
solid_objects (0.14.7)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ a dead letter.

`emit` creates a staged effect:

Typed applications can generate [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures)
to check callback names and staged `schedule`/`transmit` calls without changing
their Ruby syntax. Effect and commit-action registry names remain independent.

```ruby
emit(
:charge_payment,
Expand Down
90 changes: 90 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,96 @@ This requires no internal runtime imports. See the
The gem's strict payload target checks the actual constructors; its packaged
consumer test also verifies that missing keys and incorrect field types fail.

### Actor-specific dispatch signatures

Typed applications can opt into `SolidObjects::ActorSignatures` to check ordinary
`schedule`, `transmit`, and effect callback names inside actor methods. Add `rbs`
and `steep` to the application's development dependencies. This tool is loaded
explicitly and is not required by workers or ordinary Ruby applications.

Declare application operation types first, including inherited operations and
block-defined `message` operations. Inline RBS can generate this input, or keep
handwritten declarations in a separate directory such as `sig/actors`:

```rbs
class ChatRun < SolidObjects::Actor
def recover_if_stuck: (generation: Integer) -> nil
def fail_turn: (effect_id: String, arguments: Hash[String, untyped], error: Hash[String, untyped]) -> nil
def start: () -> nil
end
```

After loading the application's actor classes, generate a separate output file:

```ruby
require "solid_objects/actor_signatures"

Rails.application.reloader.wrap do
signatures = SolidObjects::ActorSignatures.generate(
actors: [ChatRun],
signatures: [Rails.root.join("sig/actors").to_s]
)
FileUtils.mkdir_p(Rails.root.join("sig/generated"))
File.write(Rails.root.join("sig/generated/solid_objects.rbs"), signatures)
end
```

Run that script with `bin/rails runner` during development or CI. Outside Rails,
require the actor definitions and call `generate` directly. The generator returns
a string and does not write files, execute actor operations, start workers, or run
migrations. Rails boot follows the application's normal loading configuration;
the explicit actor list resolves its autoloaded classes within the reloader boundary.
Keep generated output out of the input signature paths, and regenerate after a
message is renamed or removed. Output is deterministic; handwritten signatures
remain separate.

Load the gem and both application signature directories in `Steepfile`:

```ruby
target :actors do
library "solid_objects"
signature "sig/actors"
signature "sig/generated"
check "app/actors"
configure_code_diagnostics(Diagnostic::Ruby.strict)
end
```

The usual Ruby code now passes Steep without a dispatcher cast:

```ruby
schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1)
transmit.recover_if_stuck(generation: 1)
emit :run_model, generation: 1, on_failure: :fail_turn
```

Misspelled operations/callbacks, queries, attributes, private methods, infrastructure
methods, and incorrect keyword arguments fail the strict check. Both string and
symbol callback literals work. Staging returns `nil` even when an operation's own
return type differs. Effect and commit-action names remain global registry names;
registry contract inference is separate work.

Reflection supplies message names only. Values come from declared RBS signatures;
missing signatures and positional/block arguments are rejected. Block-defined
messages require explicit method declarations in RBS; annotate their block-local
values separately when checking the block body. Generic actor classes currently
require application-owned dispatcher signatures. The generator preserves method
overloads and method type parameters.

Deliberately dynamic names can use Ruby's explicit dynamic dispatch:

```ruby
schedule(at: Time.now).public_send(operation_name, generation: generation)
public_send(:emit, :run_model, on_failure: callback_name, generation: generation)
```

Those calls opt out of name/argument checking and retain the existing runtime
validation. Ordinary calls on the generated dispatcher have no string-name fallback.
`send_to`, `Reference#async`, direct calls, and queries retain their existing
signatures and runtime behavior; this generator does not provide complete reference
typing or Sorbet/Tapioca actor-specific RBI generation. The corresponding TypeScript
work is tracked in [solid-objects-js#46](https://github.com/cardmagic/solid-objects-js/issues/46).

## Formatting and security

```bash
Expand Down
6 changes: 6 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ Rails schema migrations and actor state migrations are separate concerns.

### Host application tooling

RBS/Steep applications can generate
[actor-specific dispatch signatures](development.md#actor-specific-dispatch-signatures)
for reminders, transmit calls, and effect callback names. This is optional development
tooling; generated signatures do not change runtime dispatch. The generator does
not supply equivalent Sorbet/Tapioca actor-specific types.

Installed engine migrations are copied as
`db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
`Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
Expand Down
4 changes: 4 additions & 0 deletions docs/reminders.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ For watchdogs paired with an effect's give-up callback, see
[typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
to retain the original argument types without repeating the error hash contract.

For static checking of watchdog operation names and keyword arguments, opt into
[actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures).
The Ruby `schedule(...).recover_if_stuck(generation: ...)` syntax stays the same.

Pass `key:` when an actor is waiting on several things at once. The key is your
own identifier for the item, and it names that item's alarm, so each item gets
one:
Expand Down
4 changes: 4 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
gem's dependencies
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
RuboCop policy, and a warning-free Brakeman scan
- Opt-in actor-specific RBS generation for schedule/transmit keyword arguments
and effect callback names, using application declarations and checked consumer
fixtures. Dynamic names remain an explicit escape hatch; complete reference
typing and actor-specific RBI generation are separate work
- Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
range is verified rather than assumed. The compatibility job runs SQLite only;
Expand Down
80 changes: 80 additions & 0 deletions lib/solid_objects/actor_signatures.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# rbs_inline: enabled

require "solid_objects"
require "rbs"
require "pathname"

module SolidObjects
class ActorSignatures
# @rbs (actors: Array[Class], signatures: Array[String]) -> String
def self.generate(actors:, signatures:)
new(signatures:).generate(actors:)
end

# @rbs @builder: untyped

# @rbs (signatures: Array[String]) -> void
def initialize(signatures:)
loader = RBS::EnvironmentLoader.new
loader.add(path: Pathname.new(File.expand_path("../../sig", __dir__)))
signatures.sort.each { |path| loader.add(path: Pathname.new(path)) }
environment = RBS::Environment.from_loader(loader).resolve_type_names
@builder = RBS::DefinitionBuilder.new(env: environment)
end

# @rbs (actors: Array[Class]) -> String
def generate(actors:)
actors.uniq.sort_by { |actor| actor.name.to_s }.map { |actor| actor_signature(actor) }.join("\n")
end

private

# @rbs (untyped) -> String
def actor_signature(actor)
unless actor < Actor && actor.name
raise ArgumentError, "actor signatures require named SolidObjects::Actor subclasses"
end

name = RBS::TypeName.parse("::#{actor.name}")
definition = @builder.build_instance(name)
if definition.type_params.any?
raise ArgumentError, "generic actor classes require application-owned dispatcher signatures"
end
messages = actor.definition.messages.keys.sort
methods = messages.map do |operation|
method = definition.methods[operation]
unless method && method.accessibility == :public
raise ArgumentError, "declare a public RBS signature for #{actor.name}##{operation}"
end
types = method.method_types.map { |type| staged_type(type, actor.name, operation).to_s }
" def #{operation}: #{types.join("\n | ")}"
end
callback_names = messages.flat_map { |operation| [ operation.inspect, operation.to_s.inspect ] }
callbacks = (callback_names + [ "nil" ]).join(" | ")
<<~RBS
class #{name}
interface _SolidObjectsOperations
#{methods.join("\n")}
def public_send: (Symbol | String, **untyped) -> nil
end

def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations
def transmit: () -> #{name}::_SolidObjectsOperations
def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), **untyped) -> nil
end
RBS
end

# @rbs (untyped, String, Symbol) -> untyped
def staged_type(method_type, actor_name, operation)
function = method_type.type
if !function.is_a?(RBS::Types::Function) || method_type.block ||
function.required_positionals.any? || function.optional_positionals.any? ||
function.rest_positionals || function.trailing_positionals.any?
raise ArgumentError, "#{actor_name}##{operation} must declare keyword-only arguments without a block"
end

method_type.update(type: function.update(return_type: RBS::Types::Bases::Nil.new(location: nil)))
end
end
end
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.14.6"
VERSION = "0.14.7"
end
24 changes: 24 additions & 0 deletions sig/generated/lib/solid_objects/actor_signatures.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated from lib/solid_objects/actor_signatures.rb with RBS::Inline

module SolidObjects
class ActorSignatures
# @rbs (actors: Array[Class], signatures: Array[String]) -> String
def self.generate: (actors: Array[Class], signatures: Array[String]) -> String

@builder: untyped

# @rbs (signatures: Array[String]) -> void
def initialize: (signatures: Array[String]) -> void

# @rbs (actors: Array[Class]) -> String
def generate: (actors: Array[Class]) -> String

private

# @rbs (untyped) -> String
def actor_signature: (untyped) -> String

# @rbs (untyped, String, Symbol) -> untyped
def staged_type: (untyped, String, Symbol) -> untyped
end
end
Loading
Loading