Skip to content

Commit e49c694

Browse files
authored
Merge pull request #49 from cardmagic/feat/transmission-receive
feat: the transmit family, ingest and staging
2 parents e704870 + 0e65004 commit e49c694

27 files changed

Lines changed: 1306 additions & 13 deletions

CHANGELOG.md

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

3+
## 0.14.0 - 2026-08-22
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. On a registry miss under Rails, `receive` loads the
15+
application's actor classes once and retries, because a lazy-loading web
16+
process has no other reason to have loaded the target class. Golden
17+
fixtures in `compatibility/transmit-envelopes.json` pin the wire contract
18+
shared with the JS runtime.
19+
- Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
20+
side of the transmit family. `transmit.increment(amount:)` stages a
21+
`solid-objects.transmit` effect in the same commit as the state change;
22+
`register_transmit` drains staged effects into camelCase envelopes and
23+
hands each to the delivery block, which raises to retry. A claimed
24+
transmit effect delivers every undelivered sibling for its actor up to
25+
its own mailbox sequence, oldest first, so per-actor order survives a
26+
failed delivery, and the receiving side dedups on `transmit:<effectId>`.
27+
A raw `emit "solid-objects.transmit"` with explicit `actorType` and
28+
`actorId` targets a different actor, matching the JS staging surface.
29+
- Mount `POST /solid_objects/transmit` in the engine, an ingest route
30+
behind the new deny-by-default `authorize_transmission` policy. The
31+
policy receives the parsed envelope and the controller, an unauthorized
32+
envelope gets 403, and a permanently unappliable one gets 422, so a
33+
sending outbox dead-letters it instead of retrying forever. The new
34+
`transmission_actor_type_resolver` configuration maps diverged actor
35+
type names for the engine route.
36+
337
## 0.13.3 - 2026-08-18
438

539
- Stop loading `ActiveRecord::Base` when the gem is required. The engine now

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.13.3)
4+
solid_objects (0.14.0)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.13.3)
387+
solid_objects (0.14.0)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# rbs_inline: enabled
2+
3+
require "action_controller/api"
4+
5+
module SolidObjects
6+
class TransmissionsController < ActionController::API
7+
# @rbs () -> void
8+
def create
9+
envelope = JSON.parse(request.body.read)
10+
return head :forbidden unless authorized_transmission?(envelope)
11+
12+
Transmission.receive(
13+
envelope,
14+
resolve_actor_type: SolidObjects.configuration.transmission_actor_type_resolver
15+
)
16+
head :ok
17+
rescue JSON::ParserError, InvalidTransmission, UnknownActorType, UnknownMessage,
18+
PayloadTooLarge, IdempotencyConflict
19+
head :unprocessable_entity
20+
end
21+
22+
private
23+
24+
# @rbs (untyped) -> bool
25+
def authorized_transmission?(envelope)
26+
SolidObjects.configuration.authorize_transmission.call(
27+
envelope:,
28+
authorization_context: self
29+
)
30+
end
31+
end
32+
end
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+
}

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# rbs_inline: enabled
22

33
SolidObjects::Engine.routes.draw do
4+
post :transmit, to: "transmissions#create"
45
get :components, to: "components#show"
56
get "components/batch", to: "components#batch"
67
resources :instances, only: %i[index show]

docs/roadmap.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@
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+
- 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`. The engine mounts
85+
`POST /solid_objects/transmit` behind a deny-by-default
86+
`authorize_transmission` policy with a configurable actor type resolver.
87+
Bidirectional replication as a declared surface, with echo suppression,
88+
is not implemented
7589
- A JavaScript suite covering every browser module, run in CI with Node's test
7690
runner and jsdom, plus a browser suite running the same modules against real
7791
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
@@ -129,7 +143,10 @@
129143
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
130144
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
131145
brake and not a stop, since a pass already in flight finishes its turn and a
132-
synchronous caller waiting on a paused instance times out. The page cost was
146+
synchronous caller waiting on a paused instance times out. Retry also only
147+
exists for message dead letters: a dead effect or broadcast has no retry
148+
API, which matters for transmit effects because a dead one is a lost
149+
replay until an operator returns its row to pending. The page cost was
133150
reasoned about rather than measured: the summary bar issues a fixed set of
134151
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
135152
monitors, but no dashboard latency has been benchmarked against a large

0 commit comments

Comments
 (0)