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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## 0.14.0 - 2026-08-22

- Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
the browser transmit family in solid-objects-js. It validates a camelCase
transmit envelope, resolves the actor type through an optional
`resolve_actor_type:` proc, and enqueues one internal message with the
idempotency key `transmit:<effectId>`, so a replayed envelope applies
once. Malformed envelopes raise the new
`SolidObjects::InvalidTransmission`. Internal delivery skips
`authorize_message`, so the host application must authenticate the
request before it calls `receive`; see `docs/transmission.md` for the
controller boundary. On a registry miss under Rails, `receive` loads the
application's actor classes once and retries, because a lazy-loading web
process has no other reason to have loaded the target class. Golden
fixtures in `compatibility/transmit-envelopes.json` pin the wire contract
shared with the JS runtime.
- Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
side of the transmit family. `transmit.increment(amount:)` stages a
`solid-objects.transmit` effect in the same commit as the state change;
`register_transmit` drains staged effects into camelCase envelopes and
hands each to the delivery block, which raises to retry. A claimed
transmit effect delivers every undelivered sibling for its actor up to
its own mailbox sequence, oldest first, so per-actor order survives a
failed delivery, and the receiving side dedups on `transmit:<effectId>`.
A raw `emit "solid-objects.transmit"` with explicit `actorType` and
`actorId` targets a different actor, matching the JS staging surface.
- Mount `POST /solid_objects/transmit` in the engine, an ingest route
behind the new deny-by-default `authorize_transmission` policy. The
policy receives the parsed envelope and the controller, an unauthorized
envelope gets 403, and a permanently unappliable one gets 422, so a
sending outbox dead-letters it instead of retrying forever. The new
`transmission_actor_type_resolver` configuration maps diverged actor
type names for the engine route.

## 0.13.3 - 2026-08-18

- Stop loading `ActiveRecord::Base` when the gem is required. The engine now
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.13.3)
solid_objects (0.14.0)
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.13.3)
solid_objects (0.14.0)
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
32 changes: 32 additions & 0 deletions app/controllers/solid_objects/transmissions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# rbs_inline: enabled

require "action_controller/api"

module SolidObjects
class TransmissionsController < ActionController::API
# @rbs () -> void
def create
envelope = JSON.parse(request.body.read)
return head :forbidden unless authorized_transmission?(envelope)

Transmission.receive(
envelope,
resolve_actor_type: SolidObjects.configuration.transmission_actor_type_resolver
)
head :ok
rescue JSON::ParserError, InvalidTransmission, UnknownActorType, UnknownMessage,
PayloadTooLarge, IdempotencyConflict
head :unprocessable_entity
end

private

# @rbs (untyped) -> bool
def authorized_transmission?(envelope)
SolidObjects.configuration.authorize_transmission.call(
envelope:,
authorization_context: self
)
end
end
end
89 changes: 89 additions & 0 deletions compatibility/transmit-envelopes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
{
"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.",
"valid": [
{
"name": "increment with arguments",
"envelope": {
"effectId": "fixture-effect-0001",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment",
"arguments": { "amount": 2 }
},
"idempotencyKey": "transmit:fixture-effect-0001"
},
{
"name": "increment without arguments",
"envelope": {
"effectId": "fixture-effect-0002",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment"
},
"idempotencyKey": "transmit:fixture-effect-0002"
}
],
"duplicatePair": [
{
"effectId": "fixture-effect-0003",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment",
"arguments": { "amount": 1 }
},
{
"effectId": "fixture-effect-0003",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment",
"arguments": { "amount": 1 }
}
],
"malformed": [
{
"name": "missing effectId",
"envelope": {
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment"
}
},
{
"name": "empty actorId",
"envelope": {
"effectId": "fixture-effect-0004",
"actorType": "transmit-counters",
"actorId": "",
"operation": "increment"
}
},
{
"name": "snake_case keys",
"envelope": {
"effect_id": "fixture-effect-0005",
"actor_type": "transmit-counters",
"actor_id": "fixture-counter",
"operation": "increment"
}
},
{
"name": "non-string operation",
"envelope": {
"effectId": "fixture-effect-0006",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": 7
}
},
{
"name": "arguments not an object",
"envelope": {
"effectId": "fixture-effect-0007",
"actorType": "transmit-counters",
"actorId": "fixture-counter",
"operation": "increment",
"arguments": [ 1 ]
}
}
]
}
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# rbs_inline: enabled

SolidObjects::Engine.routes.draw do
post :transmit, to: "transmissions#create"
get :components, to: "components#show"
get "components/batch", to: "components#batch"
resources :instances, only: %i[index show]
Expand Down
19 changes: 18 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@
Rails 7.1 and 7.2 is unmeasured against those servers. Rails 7.0 is out of
range because its SQLite adapter requires `sqlite3 ~> 1.4`, and this gem needs
the busy-handler control that arrived in `sqlite3` 2.x
- The transmit family, both sides. `SolidObjects::Transmission.receive` is
the ingest: envelope validation, actor type resolution with a per-call
`resolve_actor_type:` escape hatch, and an internal idempotent enqueue
keyed `transmit:<effectId>`. `Actor#transmit` and
`SolidObjects.register_transmit` are the staging side: a transactional
`solid-objects.transmit` effect and a drain that delivers every
undelivered sibling for the actor up to the claimed effect's mailbox
sequence, oldest first, so per-actor order survives a failed delivery.
The wire contract is pinned by golden fixtures in
`compatibility/transmit-envelopes.json`. The engine mounts
`POST /solid_objects/transmit` behind a deny-by-default
`authorize_transmission` policy with a configurable actor type resolver.
Bidirectional replication as a declared surface, with echo suppression,
is not implemented
- A JavaScript suite covering every browser module, run in CI with Node's test
runner and jsdom, plus a browser suite running the same modules against real
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
Expand Down Expand Up @@ -129,7 +143,10 @@
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
brake and not a stop, since a pass already in flight finishes its turn and a
synchronous caller waiting on a paused instance times out. The page cost was
synchronous caller waiting on a paused instance times out. Retry also only
exists for message dead letters: a dead effect or broadcast has no retry
API, which matters for transmit effects because a dead one is a lost
replay until an operator returns its row to pending. The page cost was
reasoned about rather than measured: the summary bar issues a fixed set of
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
monitors, but no dashboard latency has been benchmarked against a large
Expand Down
Loading
Loading