Skip to content

Commit 3a83337

Browse files
committed
feat: type Ruby effect callback payloads
Publish reusable RBS records and check the runtime constructors and a packaged consumer with strict Steep diagnostics. Preserve existing serialized hashes, keyword callbacks, and retry behavior. Closes #64
1 parent 395be30 commit 3a83337

17 files changed

Lines changed: 403 additions & 16 deletions

‎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+
- Publish RBS contracts for effect callback envelopes and Ruby error summaries.
6+
Check the runtime constructors and packaged consumer signatures strictly,
7+
preserving ordinary hashes, keyword callbacks, serialization, and retries.
8+
39
## 0.14.6 - 2026-09-12
410

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

‎Rakefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ task :rbs do
1414
FileUtils.rm_rf(File.expand_path("sig/generated", __dir__))
1515
sh "bundle exec rbs-inline --base lib --base app --output sig/generated lib app"
1616
FileUtils.rm_f(File.expand_path("sig/generated/lib/generators/solid_objects/templates/solid_objects.rbs", __dir__))
17-
sh "bundle exec rbs -I sig/generated -I sig/support validate"
17+
sh "bundle exec rbs -I sig/generated -I sig/support -I sig/public validate"
1818
end
1919

2020
desc "Run Standard Ruby"

‎Steepfile‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
target :lib do
44
signature "sig/generated"
55
signature "sig/support"
6+
signature "sig/public"
67
check "lib"
78

89
configure_code_diagnostics(Diagnostic::Ruby.lenient)
910

1011
ignore "lib/solid_objects/engine.rb"
12+
ignore "lib/solid_objects/effect_payload.rb"
13+
end
14+
15+
target :effect_payloads do
16+
signature "sig/generated"
17+
signature "sig/support"
18+
signature "sig/public"
19+
check "lib/solid_objects/effect_payload.rb"
20+
21+
configure_code_diagnostics(Diagnostic::Ruby.strict)
1122
end

‎docs/architecture.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,50 @@ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
483483
`error:`, so an actor can correlate concurrent effects without storing a
484484
separate callback ledger.
485485

486+
### Typing your on_failure handler
487+
488+
The gem ships `SolidObjects::effect_error`,
489+
`SolidObjects::effect_failure_payload[Arguments]`, and
490+
`SolidObjects::effect_success_payload[Arguments, Result]` as public RBS aliases.
491+
They describe the existing string-keyed hashes; they are not Ruby wrapper classes.
492+
See [signature loading](development.md#public-effect-payload-signatures) for Steep setup.
493+
494+
For an actor with `generation` and `status` attributes, declare the callback keywords
495+
in the application's RBS:
496+
497+
```rbs
498+
class ChatRun < SolidObjects::Actor
499+
type run_arguments = { "generation" => Integer }
500+
def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> void
501+
end
502+
```
503+
504+
```ruby
505+
def fail_turn(effect_id:, arguments:, error:)
506+
return unless arguments["generation"] == generation
507+
508+
self.status = "failed"
509+
end
510+
```
511+
512+
Record access with `arguments["generation"]` retains its declared `Integer` type.
513+
The callback receives top-level keywords, while nested arguments and error keys
514+
remain strings. The failure envelope requires `"effect_id"`, `"arguments"`, and
515+
`"error"`; the success envelope replaces `"error"` with `"result"`. Empty original
516+
arguments remain `{}`, and a success result may be `nil` or any supported JSON value.
517+
Ruby errors contain `"class"` (`String?`, including anonymous exception classes),
518+
`"message"` (`String`, limited to 8,192 bytes), and `"backtrace"` (`Array[String]`,
519+
limited to 50 entries and possibly empty).
520+
521+
Applications supply the generic argument/result types to describe their serialized
522+
JSON values. These aliases do not infer or validate independently registered effect
523+
handlers. Their type parameters are deliberately unconstrained: Ruby serialization
524+
accepts and normalizes values such as symbols, and RBS cannot express that conversion
525+
as a generic bound. The constructors and consumer fixtures are checked with strict
526+
Steep diagnostics. JavaScript exposes equivalent contracts with its existing
527+
camelCase ID and `{ name, message }` error shape in
528+
[solid-objects-js#47](https://github.com/cardmagic/solid-objects-js/issues/47).
529+
486530
A commit action is registered the same way and runs inside the short fenced
487531
transaction:
488532

‎docs/development.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ bundle exec rake rbs
103103

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

106+
### Public effect payload signatures
107+
108+
The packaged `sig/public` directory owns the reusable effect payload aliases and
109+
survives `rake rbs` regeneration. A host application's Steep target can load the
110+
installed gem's complete signature tree alongside its own signatures:
111+
112+
```ruby
113+
target :app do
114+
signature File.join(Gem::Specification.find_by_name("solid_objects").full_gem_path, "sig")
115+
signature "sig"
116+
check "app/actors"
117+
configure_code_diagnostics(Diagnostic::Ruby.strict)
118+
end
119+
```
120+
121+
This requires no internal runtime imports. See the
122+
[typed callback example](architecture.md#typing-your-on_failure-handler).
123+
The gem's strict payload target checks the actual constructors; its packaged
124+
consumer test also verifies that missing keys and incorrect field types fail.
125+
106126
## Formatting and security
107127

108128
```bash

‎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 watchdogs paired with an effect's give-up callback, see
43+
[typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
44+
to retain the original argument types without repeating the error hash contract.
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
dead letters, and tail retry
1515
- Transactional effects with success/failure actor messages carrying the
1616
originally staged arguments for callback correlation
17+
- Public RBS effect success/failure envelopes and error records, checked against
18+
the runtime constructors and a packaged consumer with strict Steep diagnostics
1719
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
1820
portable status rows with polling indexes and database check constraints on
1921
status, which works on all three adapters; a future version may add narrow

‎lib/solid_objects.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
require "solid_objects/wake_up_adapters"
5656
require "solid_objects/polling_backoff"
5757
require "solid_objects/effect_registry"
58+
require "solid_objects/effect_payload"
5859
require "solid_objects/commit_action_registry"
5960
require "solid_objects/lease"
6061
require "solid_objects/lease_renewer"

‎lib/solid_objects/effect_executor.rb‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,11 @@ def complete(effect, result)
184184
effect: locked_effect,
185185
operation: locked_effect.success_operation,
186186
outcome: "success",
187-
arguments: {
188-
"effect_id" => locked_effect.effect_id,
189-
"arguments" => locked_effect.arguments,
190-
"result" => serialized_result
191-
}
187+
arguments: EffectPayload.success(
188+
effect_id: locked_effect.effect_id,
189+
arguments: locked_effect.arguments,
190+
result: serialized_result
191+
)
192192
)
193193
locked_effect.update!(
194194
status: "completed",
@@ -216,21 +216,17 @@ def fail_effect(effect, error)
216216
locked_effect = Effect.lock.find(effect.id)
217217
verify_claim!(locked_effect)
218218
dead = locked_effect.attempt_count >= locked_effect.max_attempts
219-
error_details = {
220-
"class" => error.class.name,
221-
"message" => error.message.to_s.byteslice(0, 8_192),
222-
"backtrace" => Array(error.backtrace).first(50)
223-
}
219+
error_details = EffectPayload.error(error)
224220
if dead
225221
result_message = enqueue_result_message(
226222
effect: locked_effect,
227223
operation: locked_effect.failure_operation,
228224
outcome: "failure",
229-
arguments: {
230-
"effect_id" => locked_effect.effect_id,
231-
"arguments" => locked_effect.arguments,
232-
"error" => error_details
233-
}
225+
arguments: EffectPayload.failure(
226+
effect_id: locked_effect.effect_id,
227+
arguments: locked_effect.arguments,
228+
error: error_details
229+
)
234230
)
235231
end
236232
locked_effect.update!(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
module EffectPayload
5+
class << self
6+
# @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
7+
def success(effect_id:, arguments:, result:)
8+
{ "effect_id" => effect_id, "arguments" => arguments, "result" => result }
9+
end
10+
11+
# @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
12+
def failure(effect_id:, arguments:, error:)
13+
{ "effect_id" => effect_id, "arguments" => arguments, "error" => error }
14+
end
15+
16+
# @rbs (Exception) -> effect_error
17+
def error(exception)
18+
message = exception.message.to_s.byteslice(0, 8_192).to_s
19+
{
20+
"class" => exception.class.name,
21+
"message" => message,
22+
"backtrace" => Array(exception.backtrace).first(50)
23+
}
24+
end
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)