Skip to content

Commit a175b47

Browse files
committed
feat: stage transmit effects with an ordered drain
Add Actor#transmit and SolidObjects.register_transmit, the staging side of the transmit family and the counterpart of the ingest in the previous commit. transmit returns the same fluent dispatcher schedule returns and stages a solid-objects.transmit effect in the actor commit; a raw emit with explicit actorType/actorId targets a different actor, matching the JS staging surface. register_transmit wraps register_effect with envelope construction and the ordered drain ported from solid-objects-js: a claimed transmit effect delivers every undelivered sibling for its actor up to its own mailbox sequence, oldest first. Per-actor order therefore survives a failed delivery, and redelivery is safe because the receiving side dedups on transmit:<effectId>. A raised delivery retries with backoff and dead-letters on exhaustion, like any other effect; a malformed staged effect raises InvalidTransmission and is skipped by sibling drains rather than blocking them. The drain-ordering test was verified against a neutered drain that delivers only the claimed effect: it fails with [2] where [1, 2] is expected, so the test observes the guarantee it claims. Closes #48
1 parent 9114425 commit a175b47

10 files changed

Lines changed: 467 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
controller boundary. Golden fixtures in
1515
`compatibility/transmit-envelopes.json` pin the wire contract shared with
1616
the JS runtime.
17+
- Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
18+
side of the transmit family. `transmit.increment(amount:)` stages a
19+
`solid-objects.transmit` effect in the same commit as the state change;
20+
`register_transmit` drains staged effects into camelCase envelopes and
21+
hands each to the delivery block, which raises to retry. A claimed
22+
transmit effect delivers every undelivered sibling for its actor up to
23+
its own mailbox sequence, oldest first, so per-actor order survives a
24+
failed delivery, and the receiving side dedups on `transmit:<effectId>`.
25+
A raw `emit "solid-objects.transmit"` with explicit `actorType` and
26+
`actorId` targets a different actor, matching the JS staging surface.
1727

1828
## 0.13.3 - 2026-08-18
1929

docs/roadmap.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@
7272
Rails 7.1 and 7.2 is unmeasured against those servers. Rails 7.0 is out of
7373
range because its SQLite adapter requires `sqlite3 ~> 1.4`, and this gem needs
7474
the busy-handler control that arrived in `sqlite3` 2.x
75-
- `SolidObjects::Transmission.receive`, the server ingest for browser
76-
transmit envelopes from solid-objects-js: envelope validation, actor type
77-
resolution with a per-call `resolve_actor_type:` escape hatch, and an
78-
internal idempotent enqueue keyed `transmit:<effectId>`, with the wire
79-
contract pinned by golden fixtures in
80-
`compatibility/transmit-envelopes.json`. Ingest only; the staging side in
81-
Ruby and an engine-mounted route are not implemented
75+
- The transmit family, both sides. `SolidObjects::Transmission.receive` is
76+
the ingest: envelope validation, actor type resolution with a per-call
77+
`resolve_actor_type:` escape hatch, and an internal idempotent enqueue
78+
keyed `transmit:<effectId>`. `Actor#transmit` and
79+
`SolidObjects.register_transmit` are the staging side: a transactional
80+
`solid-objects.transmit` effect and a drain that delivers every
81+
undelivered sibling for the actor up to the claimed effect's mailbox
82+
sequence, oldest first, so per-actor order survives a failed delivery.
83+
The wire contract is pinned by golden fixtures in
84+
`compatibility/transmit-envelopes.json`. An engine-mounted ingest route
85+
is not implemented
8286
- A JavaScript suite covering every browser module, run in CI with Node's test
8387
runner and jsdom, plus a browser suite running the same modules against real
8488
Chromium and a real Turbo build, with every GitHub Actions reference pinned to

docs/transmission.md

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,64 @@
1-
# Transmit ingest
2-
3-
`SolidObjects::Transmission.receive(envelope)` is the server side of the
4-
browser transmit family in
5-
[solid-objects-js](https://github.com/cardmagic/solid-objects-js). A
6-
solid-objects-js actor in a browser stages a transmit intent with
7-
`this.transmit().increment({ amount })` in the same transaction as its state
8-
change. The browser's effect worker drains that outbox with at-least-once
9-
delivery, per-actor order, and retry backoff. It posts one JSON envelope per
10-
effect to a route the host application owns. `Transmission.receive` replays
11-
that envelope onto a server actor.
1+
# The transmit family
2+
3+
The transmit family replays one runtime's actor operations onto another
4+
runtime over a shared wire contract. The Ruby gem holds both sides:
5+
6+
- `Actor#transmit` and `SolidObjects.register_transmit` stage and deliver
7+
envelopes. This is the sending side.
8+
- `SolidObjects::Transmission.receive(envelope)` ingests envelopes. This is
9+
the receiving side.
10+
11+
[solid-objects-js](https://github.com/cardmagic/solid-objects-js) holds the
12+
same two sides for the browser and Node. A browser actor stages a transmit
13+
intent with `this.transmit().increment({ amount })` in the same transaction
14+
as its state change; its effect worker drains that outbox with
15+
at-least-once delivery, per-actor order, and retry backoff, and posts one
16+
JSON envelope per effect to a route the host application owns. A Rails
17+
actor does the same with `transmit.increment(amount:)`. Either ingest
18+
accepts either sender, so Rails-to-Rails, Rails-to-Node, Node-to-Rails,
19+
and browser-to-Rails replication all ride one contract.
20+
21+
## The sending side
22+
23+
```ruby
24+
class Counter < SolidObjects::Actor
25+
actor_type "counters"
26+
27+
attribute :count, default: 0
28+
29+
def increment(amount: 1)
30+
self.count += amount
31+
transmit.increment(amount:)
32+
end
33+
end
34+
35+
SolidObjects.register_transmit do |envelope|
36+
DeliverToUpstream.call(envelope)
37+
end
38+
```
39+
40+
`transmit` returns the same fluent dispatcher `schedule` returns. It stages
41+
one `solid-objects.transmit` effect in the same commit as the state change,
42+
targeting the same operation on the same actor in the receiving runtime. For
43+
a different target, stage the effect directly:
44+
45+
```ruby
46+
emit "solid-objects.transmit",
47+
operation: "increment",
48+
arguments: { amount: 2 },
49+
actorType: "other-counters",
50+
actorId: "counter-1"
51+
```
52+
53+
`SolidObjects.register_transmit(&deliver)` registers the drain handler for
54+
that effect. The block receives one camelCase envelope per staged effect.
55+
Raise inside the block while the upstream is unreachable; the effect
56+
retries with backoff and dead-letters on exhaustion, like any other effect.
57+
58+
The drain keeps per-actor order across failures: a claimed transmit effect
59+
delivers every undelivered sibling for its actor up to its own mailbox
60+
sequence, oldest first. The receiving side dedups on `transmit:<effectId>`,
61+
so a redelivered envelope applies once.
1262

1363
## Wire contract
1464

@@ -79,7 +129,6 @@ SolidObjects::Transmission.receive(
79129

80130
## Scope
81131

82-
`receive` is ingest only. The staging side in Ruby, an `actor.transmit` for
83-
Ruby-to-Ruby replication, is a separate feature. An engine-mounted route
84-
with an authentication hook is a possible follow-up; it stays out because
85-
it carries authentication, CSRF, and rate-limit decisions of its own.
132+
An engine-mounted route with an authentication hook is a possible
133+
follow-up; it stays out because it carries authentication, CSRF, and
134+
rate-limit decisions of its own.

lib/solid_objects.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ def register_effect(name, &handler)
101101
effect_registry.register(name, handler)
102102
end
103103

104+
# @rbs (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
105+
def register_transmit(effect_name: Transmission::EFFECT_NAME, &deliver)
106+
raise ArgumentError, "register_transmit requires a delivery block" unless deliver
107+
108+
register_effect(effect_name) do |arguments, context|
109+
Transmission.deliver_through(
110+
effect_name: effect_name.to_s,
111+
arguments:,
112+
context:,
113+
deliver:
114+
)
115+
end
116+
end
117+
104118
# @rbs () -> CommitActionRegistry
105119
def commit_action_registry
106120
@commit_action_registry ||= CommitActionRegistry.new

lib/solid_objects/actor.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ def emit(name, on_success: nil, on_failure: nil, **arguments)
190190
nil
191191
end
192192

193+
# @rbs () -> OperationDispatcher
194+
def transmit
195+
OperationDispatcher.new(
196+
actor_type: self.class.actor_type,
197+
handlers: self.class.definition.messages
198+
) do |operation, arguments|
199+
emit(Transmission::EFFECT_NAME, operation: operation.to_s, arguments:)
200+
nil
201+
end
202+
end
203+
193204
# @rbs (Symbol | String, **untyped) -> nil
194205
def commit_action(name, **arguments)
195206
CommitActionIntent.new(

lib/solid_objects/transmission.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module SolidObjects
44
module Transmission
55
REQUIRED_FIELDS = %w[effectId actorType actorId operation].freeze
66
IDEMPOTENCY_PREFIX = "transmit:"
7+
EFFECT_NAME = "solid-objects.transmit"
8+
UNDELIVERED_STATUSES = %w[pending processing].freeze
79

810
class << self
911
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
@@ -26,8 +28,79 @@ def receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new)
2628
)
2729
end
2830

31+
# @rbs (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
32+
def deliver_through(effect_name:, arguments:, context:, deliver:)
33+
staged_envelope(
34+
arguments,
35+
effect_id: context.id,
36+
actor_type: context.actor_type,
37+
actor_id: context.actor_id
38+
)
39+
undelivered_envelopes_through(effect_name:, context:).each do |envelope|
40+
deliver.call(envelope)
41+
end
42+
nil
43+
end
44+
2945
private
3046

47+
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
48+
def staged_envelope(arguments, effect_id:, actor_type:, actor_id:)
49+
operation = arguments["operation"]
50+
unless operation.is_a?(String) && !operation.empty?
51+
raise InvalidTransmission, "transmit effect arguments require a non-empty operation"
52+
end
53+
54+
target_arguments = arguments["arguments"]
55+
target_arguments = {} if target_arguments.nil?
56+
unless target_arguments.is_a?(Hash)
57+
raise InvalidTransmission, %(transmit effect arguments must hold a JSON object in "arguments")
58+
end
59+
60+
target_type = arguments["actorType"].nil? ? actor_type : arguments["actorType"]
61+
target_id = arguments["actorId"].nil? ? actor_id : arguments["actorId"]
62+
{ "actorType" => target_type, "actorId" => target_id }.each do |field, value|
63+
next if value.is_a?(String) && !value.empty?
64+
65+
raise InvalidTransmission, "transmit effect #{field} must be a non-empty string"
66+
end
67+
68+
{
69+
"effectId" => effect_id,
70+
"actorType" => target_type,
71+
"actorId" => target_id,
72+
"operation" => operation,
73+
"arguments" => target_arguments
74+
}
75+
end
76+
77+
# @rbs (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
78+
def undelivered_envelopes_through(effect_name:, context:)
79+
source_sequence = Message.find(context.source_message_id).sequence
80+
effects = Effect
81+
.joins(:message)
82+
.where(name: effect_name, status: UNDELIVERED_STATUSES)
83+
.merge(
84+
Message.where(
85+
actor_type: context.actor_type,
86+
actor_id: context.actor_id,
87+
sequence: ..source_sequence
88+
)
89+
)
90+
.order(Message.arel_table[:sequence].asc, :id)
91+
92+
effects.filter_map do |effect|
93+
staged_envelope(
94+
effect.arguments,
95+
effect_id: effect.effect_id,
96+
actor_type: context.actor_type,
97+
actor_id: context.actor_id
98+
)
99+
rescue InvalidTransmission
100+
nil
101+
end
102+
end
103+
31104
# @rbs (untyped) -> void
32105
def validate!(envelope)
33106
raise InvalidTransmission, "envelope must be a JSON object" unless envelope.is_a?(Hash)

sig/generated/lib/solid_objects.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ module SolidObjects
1818
# @rbs (String | Symbol) { (Hash[String, untyped], EffectContext) -> untyped } -> Proc
1919
def self.register_effect: (String | Symbol) { (Hash[String, untyped], EffectContext) -> untyped } -> Proc
2020

21+
# @rbs (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
22+
def self.register_transmit: (?effect_name: String | Symbol) { (Hash[String, untyped]) -> untyped } -> Proc
23+
2124
# @rbs () -> CommitActionRegistry
2225
def self.commit_action_registry: () -> CommitActionRegistry
2326

sig/generated/lib/solid_objects/actor.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ module SolidObjects
161161
# @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
162162
def emit: (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
163163

164+
# @rbs () -> OperationDispatcher
165+
def transmit: () -> OperationDispatcher
166+
164167
# @rbs (Symbol | String, **untyped) -> nil
165168
def commit_action: (Symbol | String, **untyped) -> nil
166169

sig/generated/lib/solid_objects/transmission.rbs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,22 @@ module SolidObjects
66

77
IDEMPOTENCY_PREFIX: ::String
88

9+
EFFECT_NAME: ::String
10+
11+
UNDELIVERED_STATUSES: untyped
12+
913
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
1014
def self.receive: (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
1115

16+
# @rbs (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
17+
def self.deliver_through: (effect_name: String, arguments: Hash[String, untyped], context: EffectContext, deliver: Proc) -> nil
18+
19+
# @rbs (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
20+
private def self.staged_envelope: (Hash[String, untyped], effect_id: String, actor_type: String, actor_id: String) -> Hash[String, untyped]
21+
22+
# @rbs (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
23+
private def self.undelivered_envelopes_through: (effect_name: String, context: EffectContext) -> Array[Hash[String, untyped]]
24+
1225
# @rbs (untyped) -> void
1326
private def self.validate!: (untyped) -> void
1427
end

0 commit comments

Comments
 (0)