Skip to content

Commit 9114425

Browse files
committed
feat: receive browser transmit envelopes
Add SolidObjects::Transmission.receive, the server ingest for the browser transmit family that solid-objects-js ships. A browser actor stages a transmit intent in the same transaction as its state change; the browser outbox posts one camelCase JSON envelope per effect to a route the host application owns, and receive replays it onto a server actor. The gem already held every primitive: the ingest validates the envelope, resolves the actor type (with a per-call resolve_actor_type: escape hatch for diverged names), and enqueues one internal message keyed transmit:<effectId>, so at-least-once delivery applies once. Internal delivery skips authorize_message by construction, which is why the host must authenticate before the call; docs/transmission.md draws that boundary with a controller example. Malformed envelopes raise the new SolidObjects::InvalidTransmission, so a host can return 422 and let the browser outbox dead-letter the effect instead of retrying forever. Golden fixtures in compatibility/transmit-envelopes.json pin the wire contract; the JS repo gets the same file with a consuming test when its transmit branch lands. Closes #47
1 parent e704870 commit 9114425

10 files changed

Lines changed: 457 additions & 0 deletions

File tree

CHANGELOG.md

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

3+
## Unreleased
4+
5+
- Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
6+
the browser transmit family in solid-objects-js. It validates a camelCase
7+
transmit envelope, resolves the actor type through an optional
8+
`resolve_actor_type:` proc, and enqueues one internal message with the
9+
idempotency key `transmit:<effectId>`, so a replayed envelope applies
10+
once. Malformed envelopes raise the new
11+
`SolidObjects::InvalidTransmission`. Internal delivery skips
12+
`authorize_message`, so the host application must authenticate the
13+
request before it calls `receive`; see `docs/transmission.md` for the
14+
controller boundary. Golden fixtures in
15+
`compatibility/transmit-envelopes.json` pin the wire contract shared with
16+
the JS runtime.
17+
318
## 0.13.3 - 2026-08-18
419

520
- Stop loading `ActiveRecord::Base` when the gem is required. The engine now
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{
2+
"description": "Golden transmit envelopes shared by solid_objects and solid-objects-js. The JS bridge must produce and accept these; SolidObjects::Transmission.receive must accept the valid ones, apply the duplicate pair once, and reject the malformed ones.",
3+
"valid": [
4+
{
5+
"name": "increment with arguments",
6+
"envelope": {
7+
"effectId": "fixture-effect-0001",
8+
"actorType": "transmit-counters",
9+
"actorId": "fixture-counter",
10+
"operation": "increment",
11+
"arguments": { "amount": 2 }
12+
},
13+
"idempotencyKey": "transmit:fixture-effect-0001"
14+
},
15+
{
16+
"name": "increment without arguments",
17+
"envelope": {
18+
"effectId": "fixture-effect-0002",
19+
"actorType": "transmit-counters",
20+
"actorId": "fixture-counter",
21+
"operation": "increment"
22+
},
23+
"idempotencyKey": "transmit:fixture-effect-0002"
24+
}
25+
],
26+
"duplicatePair": [
27+
{
28+
"effectId": "fixture-effect-0003",
29+
"actorType": "transmit-counters",
30+
"actorId": "fixture-counter",
31+
"operation": "increment",
32+
"arguments": { "amount": 1 }
33+
},
34+
{
35+
"effectId": "fixture-effect-0003",
36+
"actorType": "transmit-counters",
37+
"actorId": "fixture-counter",
38+
"operation": "increment",
39+
"arguments": { "amount": 1 }
40+
}
41+
],
42+
"malformed": [
43+
{
44+
"name": "missing effectId",
45+
"envelope": {
46+
"actorType": "transmit-counters",
47+
"actorId": "fixture-counter",
48+
"operation": "increment"
49+
}
50+
},
51+
{
52+
"name": "empty actorId",
53+
"envelope": {
54+
"effectId": "fixture-effect-0004",
55+
"actorType": "transmit-counters",
56+
"actorId": "",
57+
"operation": "increment"
58+
}
59+
},
60+
{
61+
"name": "snake_case keys",
62+
"envelope": {
63+
"effect_id": "fixture-effect-0005",
64+
"actor_type": "transmit-counters",
65+
"actor_id": "fixture-counter",
66+
"operation": "increment"
67+
}
68+
},
69+
{
70+
"name": "non-string operation",
71+
"envelope": {
72+
"effectId": "fixture-effect-0006",
73+
"actorType": "transmit-counters",
74+
"actorId": "fixture-counter",
75+
"operation": 7
76+
}
77+
},
78+
{
79+
"name": "arguments not an object",
80+
"envelope": {
81+
"effectId": "fixture-effect-0007",
82+
"actorType": "transmit-counters",
83+
"actorId": "fixture-counter",
84+
"operation": "increment",
85+
"arguments": [ 1 ]
86+
}
87+
}
88+
]
89+
}

docs/roadmap.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@
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
7582
- A JavaScript suite covering every browser module, run in CI with Node's test
7683
runner and jsdom, plus a browser suite running the same modules against real
7784
Chromium and a real Turbo build, with every GitHub Actions reference pinned to

docs/transmission.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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.
12+
13+
## Wire contract
14+
15+
The JS side owns the envelope format. The Ruby ingest accepts it verbatim.
16+
17+
- Keys arrive camelCase: `effectId`, `actorType`, `actorId`, `operation`,
18+
and an optional `arguments` object. There is no snake_case dialect.
19+
- The idempotency key is `transmit:<effectId>`, byte-identical to the JS
20+
server ingest. A replayed envelope applies once.
21+
- Delivery is at-least-once and per-actor ordered by the browser's drain.
22+
The server preserves mailbox order and adds no ordering of its own.
23+
24+
`compatibility/transmit-envelopes.json` pins the contract. Both runtimes
25+
run a consuming test against the same fixture file.
26+
27+
## What `receive` does
28+
29+
1. It validates the envelope shape. A malformed envelope raises
30+
`SolidObjects::InvalidTransmission`.
31+
2. It resolves the actor type and looks it up in the registry. An unknown
32+
type raises `SolidObjects::UnknownActorType`. An undeclared operation
33+
raises `SolidObjects::UnknownMessage`.
34+
3. It enqueues one internal message with the idempotency key
35+
`transmit:<effectId>`. Oversized arguments raise
36+
`SolidObjects::PayloadTooLarge` before persistence.
37+
38+
The enqueue uses `delivery_mode: "internal"`, the same mode the effect
39+
executor uses. Internal delivery skips `authorize_message` by construction.
40+
The host application must authenticate the request before it calls
41+
`receive`.
42+
43+
## The host application owns HTTP
44+
45+
The gem draws the same boundary here that it draws for realtime transport:
46+
the host owns the route, authentication, and rate limits.
47+
48+
```ruby
49+
class TransmitController < ApplicationController
50+
skip_forgery_protection
51+
52+
def create
53+
head :forbidden and return unless authenticated_device?
54+
55+
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
56+
head :ok
57+
rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownActorType,
58+
SolidObjects::UnknownMessage, SolidObjects::PayloadTooLarge, JSON::ParserError
59+
head :unprocessable_entity
60+
end
61+
end
62+
```
63+
64+
Return 422 for an envelope the server can never apply. The browser outbox
65+
dead-letters that effect instead of retrying it forever. Return a 5xx for a
66+
transient server fault, so the browser retries with backoff.
67+
68+
## Actor type mapping
69+
70+
When both runtimes use the same actor type strings, no configuration is
71+
needed. When the names diverge, pass `resolve_actor_type:` per call:
72+
73+
```ruby
74+
SolidObjects::Transmission.receive(
75+
envelope,
76+
resolve_actor_type: ->(actor_type) { actor_type.sub("browser-", "server-") }
77+
)
78+
```
79+
80+
## Scope
81+
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.

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
# was reachable only through the caller path, so requiring the gem was not
6464
# enough to run a role that uses it.
6565
require "solid_objects/mailbox"
66+
require "solid_objects/transmission"
6667
require "solid_objects/worker"
6768
require "solid_objects/effect_executor"
6869
require "solid_objects/reminder_scheduler"

lib/solid_objects/errors.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class UnknownActorType < Error
1919
class UnknownMessage < Error
2020
end
2121

22+
class InvalidTransmission < Error
23+
end
24+
2225
class InvalidPayload < Error
2326
end
2427

lib/solid_objects/transmission.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
module Transmission
5+
REQUIRED_FIELDS = %w[effectId actorType actorId operation].freeze
6+
IDEMPOTENCY_PREFIX = "transmit:"
7+
8+
class << self
9+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
10+
def receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new)
11+
validate!(envelope)
12+
13+
actor_type = resolve_actor_type.call(envelope["actorType"]).to_s
14+
actor_class = SolidObjects.registry.fetch(actor_type)
15+
operation = envelope["operation"].to_sym
16+
unless actor_class.definition.messages.key?(operation)
17+
raise UnknownMessage, "unknown operation #{envelope["operation"].inspect}"
18+
end
19+
20+
mailbox.enqueue(
21+
reference: Reference.new(actor_type:, actor_id: envelope["actorId"]),
22+
operation:,
23+
arguments: envelope.fetch("arguments", {}),
24+
delivery_mode: "internal",
25+
idempotency_key: "#{IDEMPOTENCY_PREFIX}#{envelope["effectId"]}"
26+
)
27+
end
28+
29+
private
30+
31+
# @rbs (untyped) -> void
32+
def validate!(envelope)
33+
raise InvalidTransmission, "envelope must be a JSON object" unless envelope.is_a?(Hash)
34+
35+
REQUIRED_FIELDS.each do |field|
36+
value = envelope[field]
37+
next if value.is_a?(String) && !value.empty?
38+
39+
raise InvalidTransmission, "envelope field #{field.inspect} must be a non-empty string"
40+
end
41+
42+
arguments = envelope.fetch("arguments", {})
43+
return if arguments.is_a?(Hash)
44+
45+
raise InvalidTransmission, %(envelope field "arguments" must be a JSON object)
46+
end
47+
end
48+
end
49+
end

sig/generated/lib/solid_objects/errors.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ module SolidObjects
1919
class UnknownMessage < Error
2020
end
2121

22+
class InvalidTransmission < Error
23+
end
24+
2225
class InvalidPayload < Error
2326
end
2427

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Generated from lib/solid_objects/transmission.rb with RBS::Inline
2+
3+
module SolidObjects
4+
module Transmission
5+
REQUIRED_FIELDS: untyped
6+
7+
IDEMPOTENCY_PREFIX: ::String
8+
9+
# @rbs (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
10+
def self.receive: (untyped envelope, ?resolve_actor_type: ^(String) -> (String | Symbol), ?mailbox: Mailbox) -> MessageReference
11+
12+
# @rbs (untyped) -> void
13+
private def self.validate!: (untyped) -> void
14+
end
15+
end

0 commit comments

Comments
 (0)