solid_objects 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +19 -11
  4. data/Rakefile +5 -0
  5. data/app/controllers/solid_objects/transmissions_controller.rb +32 -0
  6. data/config/routes.rb +1 -0
  7. data/docs/correctness.md +9 -0
  8. data/docs/operations.md +23 -1
  9. data/docs/research/solid_queue.md +2 -2
  10. data/docs/roadmap.md +25 -2
  11. data/docs/state-migrations.md +1 -1
  12. data/docs/transmission.md +198 -0
  13. data/examples/at_least_once/actor.rb +14 -0
  14. data/examples/at_least_once/boot.rb +47 -0
  15. data/examples/at_least_once/demo.rb +92 -0
  16. data/examples/at_least_once/effect_worker.rb +40 -0
  17. data/examples/at_least_once/sink.rb +27 -0
  18. data/lib/generators/solid_objects/templates/solid_objects.rb +17 -1
  19. data/lib/solid_objects/actor.rb +11 -0
  20. data/lib/solid_objects/actor_channel.rb +33 -3
  21. data/lib/solid_objects/configuration.rb +7 -1
  22. data/lib/solid_objects/engine.rb +4 -0
  23. data/lib/solid_objects/errors.rb +3 -0
  24. data/lib/solid_objects/transmission.rb +139 -0
  25. data/lib/solid_objects/version.rb +1 -1
  26. data/lib/solid_objects.rb +16 -0
  27. data/sig/generated/controllers/solid_objects/transmissions_controller.rbs +13 -0
  28. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  29. data/sig/generated/lib/solid_objects/actor_channel.rbs +10 -0
  30. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  31. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  32. data/sig/generated/lib/solid_objects/transmission.rbs +34 -0
  33. data/sig/generated/lib/solid_objects.rbs +3 -0
  34. data/sig/support/framework.rbs +3 -0
  35. metadata +16 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9045039767fa2e9ff5a4b5cd2bceb97ca5bc8ea0101b41aaac8fcefcf8a694ef
4
- data.tar.gz: efed76c2524be2f7a9591a631c56355f95edaf39332cea846fb895363b8d8d68
3
+ metadata.gz: d3b9abc40f188e1ea4964cfffca89e030990948c937a8aca0ec32c19830da272
4
+ data.tar.gz: 57707f08fc468d705202bbc3f20ddd60bfed4cd3d9153c58b7a06c981051ef93
5
5
  SHA512:
6
- metadata.gz: 691972f84f5304cbe3f3be42b5889697b179f1eca38e55d76f87827e0ffc5c095ec97c0d591fa58680829920c807bde08619dc725add17be7f1215439775f064
7
- data.tar.gz: 68bff7f63f6557e50a6262e22b33fd7633372ec77a33e35b37f95d074498b66b98b01527776b18acbe2ae6527133c6bf7588e50f98e501a0c5636101dac6f45d
6
+ metadata.gz: 203477264f47ba941dd4a29b08e00ce5e8ccce298fce361beeaed73f54d3fb1edbbd46f05733e44eaacb366301fc1a6128f084024976268e42ce302d6a7aab76
7
+ data.tar.gz: 1f82eeb81cd282bbf0cdabc3e4346376b21f5688997440a3f6790f3332f2762764d3a3e3a48778629702c0604480e4e0a6380a677d76a106d9f439c466302cf4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1 - 2026-08-24
4
+
5
+ - Register application actors in every process that boots the application.
6
+ The engine now loads the host application's `app/actors` directories from a
7
+ `to_prepare` hook, which previously only the `solid_objects start` process
8
+ did. An actor registers itself as a side effect of its class loading, so a
9
+ lazily loading web process began with an empty registry. `ActorChannel`
10
+ looks the actor up by name, and the resulting `UnknownActorType` reached the
11
+ rescue that rejects the subscription: a Cable subscription for a real actor
12
+ was rejected in any web process that had not yet rendered that actor, and
13
+ the page kept a card that never updated. `ComponentsController` resolves the
14
+ same way through `ActorSnapshot`. `Transmission.receive` already carried a
15
+ registry-miss retry for this reason, and it stays as a guard for a host that
16
+ reaches the gem without the engine.
17
+ - Report why a Cable subscription was rejected. Every reject path in
18
+ `ActorChannel#subscribed` now emits `solid_objects.subscription.rejected`
19
+ with a `reason`, the actor identity, and the `error_class` where an
20
+ exception caused it. Five conditions previously collapsed into one silent
21
+ `reject`, which is invisible from the browser and left nothing in the log to
22
+ distinguish an unregistered actor type from a tampered token. Exception
23
+ messages stay out of the payload, because a component or payload failure can
24
+ carry actor state.
25
+
26
+ - State where `async` waits when no worker runs. The `async` section of the
27
+ README and the runtime section of `docs/operations.md` now say that the
28
+ generator and the migrations start no role, so an application that serves
29
+ web requests alone leaves the message ready until
30
+ `bundle exec solid_objects start` runs the roles. The message is durable
31
+ and waits; it is not lost. `test/integration/background_pickup_test.rb`
32
+ pins it: the message reads `ready` and the actor state stays empty until a
33
+ worker runs. This matches solid-objects-js#22, which reported the same gap
34
+ for `runtime.run(signal)` in the Node package.
35
+
36
+ - Add `examples/at_least_once` and `bundle exec rake at_least_once`, an
37
+ executable proof that the at-least-once clause fires and that the
38
+ documented remedy absorbs it. One actor turn stages an effect that writes
39
+ to an external sink file. The first effect worker crashes between the sink
40
+ write and the acknowledgement, and a second worker reclaims the stale
41
+ effect after the liveness threshold and delivers again. With deduplication
42
+ off the sink reads 2, both deliveries carrying the same `context.id` at
43
+ attempts 1 and 2; with a guard on that id the sink reads 1. The actor state
44
+ commits exactly once in both runs. CI runs the demo in the SQLite job, and
45
+ `docs/correctness.md` links it from the handler idempotency section. This
46
+ mirrors `pnpm run test:at-least-once` in solid-objects-js.
47
+
48
+ ## 0.14.0 - 2026-08-22
49
+
50
+ - Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
51
+ the browser transmit family in solid-objects-js. It validates a camelCase
52
+ transmit envelope, resolves the actor type through an optional
53
+ `resolve_actor_type:` proc, and enqueues one internal message with the
54
+ idempotency key `transmit:<effectId>`, so a replayed envelope applies
55
+ once. Malformed envelopes raise the new
56
+ `SolidObjects::InvalidTransmission`. Internal delivery skips
57
+ `authorize_message`, so the host application must authenticate the
58
+ request before it calls `receive`; see `docs/transmission.md` for the
59
+ controller boundary. On a registry miss under Rails, `receive` loads the
60
+ application's actor classes once and retries, because a lazy-loading web
61
+ process has no other reason to have loaded the target class. Golden
62
+ fixtures in `compatibility/transmit-envelopes.json` pin the wire contract
63
+ shared with the JS runtime.
64
+ - Add `Actor#transmit` and `SolidObjects.register_transmit`, the staging
65
+ side of the transmit family. `transmit.increment(amount:)` stages a
66
+ `solid-objects.transmit` effect in the same commit as the state change;
67
+ `register_transmit` drains staged effects into camelCase envelopes and
68
+ hands each to the delivery block, which raises to retry. A claimed
69
+ transmit effect delivers every undelivered sibling for its actor up to
70
+ its own mailbox sequence, oldest first, so per-actor order survives a
71
+ failed delivery, and the receiving side dedups on `transmit:<effectId>`.
72
+ A raw `emit "solid-objects.transmit"` with explicit `actorType` and
73
+ `actorId` targets a different actor, matching the JS staging surface.
74
+ - Mount `POST /solid_objects/transmit` in the engine, an ingest route
75
+ behind the new deny-by-default `authorize_transmission` policy. The
76
+ policy receives the parsed envelope and the controller, an unauthorized
77
+ envelope gets 403, and a permanently unappliable one gets 422, so a
78
+ sending outbox dead-letters it instead of retrying forever. The new
79
+ `transmission_actor_type_resolver` configuration maps diverged actor
80
+ type names for the engine route.
81
+
3
82
  ## 0.13.3 - 2026-08-18
4
83
 
5
84
  - Stop loading `ActiveRecord::Base` when the gem is required. The engine now
data/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Solid Objects
2
2
 
3
- [![CI](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid-objects-ruby/actions/workflows/ci.yml)
4
4
 
5
5
  **Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
6
6
 
7
- Solid Objects brings the Durable Objects programming model—addressable objects,
8
- durable state, serialized turns, alarms, and live clients—to ordinary Rails
9
- applications. It runs on the MySQL, PostgreSQL, or SQLite database the
10
- application already has, following the database-backed operating model of the
7
+ Solid Objects ports the Durable Objects programming model to ordinary Rails
8
+ applications: addressable objects, durable state, serialized turns, alarms,
9
+ and live clients. It runs on the MySQL, PostgreSQL, or SQLite database that
10
+ the application already has, in the database-backed operating model of the
11
11
  Solid family. No Redis, Cloudflare account, or separate actor service is
12
12
  required.
13
13
 
@@ -113,7 +113,7 @@ maps those ideas into Rails:
113
113
  | Storage deletion | Authorized `reference.destroy` |
114
114
  | Cloudflare Workers platform | Your Rails processes and SQL database |
115
115
 
116
- Rails already has excellent tools for jobs, records, and realtime transport.
116
+ Rails already has tools for jobs, records, and realtime transport.
117
117
  None of those primitives alone provides this complete stateful-object shape.
118
118
  Solid Objects adds five capabilities:
119
119
 
@@ -662,6 +662,12 @@ message = order.async(
662
662
  ).submit
663
663
  ```
664
664
 
665
+ `async` needs a running actor worker. Installing the engine and migrating the
666
+ schema starts no role, so a process that only serves web requests leaves the
667
+ message ready. Nothing is lost. The message waits until
668
+ `bundle exec solid_objects start` runs the roles. See
669
+ [Worker requirements](#worker-requirements) for the feature-by-role table.
670
+
665
671
  Use `available_at:` to spread bulk work or delay one message:
666
672
 
667
673
  ```ruby
@@ -1117,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
1117
1123
  claimed turn is recovered after its process heartbeat or activation lease
1118
1124
  becomes stale.
1119
1125
 
1120
- Before any role starts, the CLI loads actors from the host application's
1121
- `app/actors` directories through Rails' main autoloader. This works when
1122
- development eager loading is disabled and does not require actor references in
1123
- an initializer.
1126
+ The engine loads actors from the host application's `app/actors` directories
1127
+ through Rails' main autoloader, in every process that boots the application.
1128
+ This works when eager loading is disabled and does not require actor
1129
+ references in an initializer. A web process therefore resolves an actor by
1130
+ name for a Cable subscription or a component render without having loaded that
1131
+ class through an earlier request.
1124
1132
 
1125
1133
  See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1126
1134
  shutdown, retention, and backup guidance.
@@ -1273,7 +1281,7 @@ for staged cutovers.
1273
1281
  | --- | --- |
1274
1282
  | Cloudflare Durable Objects | Solid Objects ports the named, stateful, serialized-object model to Ruby and Rails. It uses your SQL database and Rails workers rather than Cloudflare's globally distributed serverless runtime, placement, and storage APIs. |
1275
1283
  | Active Job | Jobs are independent work units. Solid Objects adds addressable identity, durable state, explicit per-identity order, activation leases, and fencing. |
1276
- | Solid Queue | Solid Queue is an excellent database backend for Active Job. Its concurrency controls cap overlap but do not guarantee order. Solid Objects provides actor mailboxes, state, fencing, per-identity reminders, and state-driven views. |
1284
+ | Solid Queue | Solid Queue is a database backend for Active Job. Its concurrency controls cap overlap but do not guarantee order. Solid Objects provides actor mailboxes, state, fencing, per-identity reminders, and state-driven views. |
1277
1285
  | Action Cable | Cable transports transient realtime messages. Solid Objects owns durable state and work; Cable is an optional delivery path for committed observable projections. |
1278
1286
  | Orleans | Orleans provides the virtual-actor lineage behind the model, with grains, reminders, and activation lifecycle. Solid Objects is a smaller Rails-native runtime and does not match Orleans clustering or placement breadth. |
1279
1287
  | Active Record service object | A service object runs directly against records. Solid Objects adds durable asynchronous ordering, retries, activation fencing, reminders, and outboxes at greater operational cost. |
data/Rakefile CHANGED
@@ -32,6 +32,11 @@ task :steep do
32
32
  sh "bundle exec steep check"
33
33
  end
34
34
 
35
+ desc "Prove the at-least-once clause by crashing an effect worker at a sink"
36
+ task :at_least_once do
37
+ sh "bundle exec ruby examples/at_least_once/demo.rb"
38
+ end
39
+
35
40
  desc "Scan the Rails engine for security warnings"
36
41
  task :security do
37
42
  sh "bundle exec brakeman --force --no-pager -q ."
@@ -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
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  SolidObjects::Engine.routes.draw do
4
+ post :transmit, to: "transmissions#create"
4
5
  get :components, to: "components#show"
5
6
  get "components/batch", to: "components#batch"
6
7
  resources :instances, only: %i[index show]
data/docs/correctness.md CHANGED
@@ -93,6 +93,15 @@ end
93
93
  The guard prevents a repeated state transition. The effect consumer still
94
94
  deduplicates with `context.id`.
95
95
 
96
+ This clause is observable, not decorative. `bundle exec rake at_least_once`
97
+ crashes an effect worker between the external sink write and the
98
+ acknowledgement, restarts one after the liveness threshold, and shows the sink
99
+ reading 2 with deduplication off. Both deliveries carry the same `context.id`
100
+ at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
101
+ sink reads 1. The actor state commits exactly once in both runs. The source is
102
+ `examples/at_least_once/`; solid-objects-js runs the same proof with
103
+ `pnpm run test:at-least-once`.
104
+
96
105
  ## Atomic boundaries
97
106
 
98
107
  The following are atomic:
data/docs/operations.md CHANGED
@@ -31,12 +31,25 @@ Start all configured roles:
31
31
  bundle exec solid_objects start
32
32
  ```
33
33
 
34
- The command loads the host application's `app/actors` directories before
34
+ The generator and the migrations prepare the database and start nothing. A
35
+ process claims ready messages only after this command starts its roles, so an
36
+ application that serves web requests alone leaves every `async` message ready.
37
+ The message is durable and waits for the first process that runs the roles. A
38
+ direct call or an explicit `sync` needs no running role, because the caller's
39
+ own path executes it.
40
+
41
+ The engine loads the host application's `app/actors` directories in every
42
+ process that boots the application, and the command repeats that load before
35
43
  starting any runtime role, even when Rails eager loading is disabled. Actors in
36
44
  the conventional directory do not need initializer references. The targeted
37
45
  loader participates in Rails preparation callbacks so a development reload can
38
46
  replace a registered actor class without loading unrelated application code.
39
47
 
48
+ An actor registers itself as its class loads, and a web process resolves
49
+ actors by name for Cable subscriptions and component renders. Loading them in
50
+ every process is what lets a freshly booted web process serve a live card for
51
+ an actor no request in that process has rendered yet.
52
+
40
53
  Inspect process records and clean stale ownership:
41
54
 
42
55
  ```bash
@@ -187,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and
187
200
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
188
201
  retention pruning, process cleanup, and supervisor lifecycle.
189
202
 
203
+ `solid_objects.subscription.rejected` reports a rejected Cable subscription.
204
+ A rejection closes the socket and leaves the page holding a stale card, and
205
+ the browser cannot say which of the conditions applied. The event carries the
206
+ `reason`, the actor identity, and the `error_class` where an exception caused
207
+ it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
208
+ `invalid_component_token`, `malformed_component_registration`,
209
+ `missing_subscription_parameter`, or `unauthorized`. Exception messages are
210
+ excluded, because a component or payload failure can carry actor state.
211
+
190
212
  `solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
191
213
  already armed under the same name on the same actor, carrying the actor
192
214
  identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
@@ -526,8 +526,8 @@ Source: [Cloudflare Durable Objects overview](https://developers.cloudflare.com/
526
526
  - Solid Queue findings are tied to v1.6.0. Earlier releases did not have all current async/fiber supervision and concurrency finalization behavior.
527
527
  - PostgreSQL documentation inspected was current PostgreSQL 18 documentation. `SKIP LOCKED` has existed since PostgreSQL 9.5, but Solid Objects supports PostgreSQL 14 and newer.
528
528
  - MySQL documentation inspected was MySQL 8.4. Solid Objects supports MySQL 8.0 and newer with InnoDB.
529
- - SQLite documentation inspected covers current SQLite behavior. Solid Objects requires SQLite 3.35 or newer for modern DML support.
530
- - Rails documentation and source inspected cover Rails 8.1. Solid Objects requires Rails 8.0 or newer because Rails 8 changed the SQLite adapter's default write transaction from deferred to immediate, which the SQLite coordination contract relies on.
529
+ - SQLite documentation inspected covers current SQLite behavior. Solid Objects supports SQLite 3.35 or newer, the oldest server version the adapter accepts.
530
+ - Rails documentation and source inspected cover Rails 8.1. Solid Objects requires Rails 7.1 or newer. Rails 8 changed the SQLite adapter's default write transaction from deferred to immediate, and the test suite also passes on Rails 7.1 and 7.2 with the deferred default. Rails 7.0 stays out of range because its SQLite adapter requires sqlite3 1.4, and the busy-handler control this gem depends on arrived in sqlite3 2.x.
531
531
  - Orleans documentation describes current Orleans behavior, not a compatibility promise for this Ruby implementation.
532
532
  - `LISTEN/NOTIFY` and Action Cable are optimizations and delivery channels, never durable truth.
533
533
  - A separate actor database is compatible only when all rows participating in an atomic actor commit, including message, state, effects, and broadcasts, live in that same actor database.
data/docs/roadmap.md CHANGED
@@ -72,6 +72,20 @@
72
72
  Rails 7.1 and 7.2 is unmeasured against those servers. Rails 7.0 is out of
73
73
  range because its SQLite adapter requires `sqlite3 ~> 1.4`, and this gem needs
74
74
  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
75
89
  - A JavaScript suite covering every browser module, run in CI with Node's test
76
90
  runner and jsdom, plus a browser suite running the same modules against real
77
91
  Chromium and a real Turbo build, with every GitHub Actions reference pinned to
@@ -112,7 +126,13 @@
112
126
  untested end to end, which is how a raising payload block came to reject the
113
127
  subscription; it is now covered and confined, and the payload authorization
114
128
  context is resolved through `payload_authorization_context` rather than
115
- handing the block a raw Cable connection.
129
+ handing the block a raw Cable connection. Actor registration in a web process
130
+ was assumed rather than arranged: an actor registered only as a side effect
131
+ of its class loading, and only the worker CLI loaded the host's `app/actors`,
132
+ so a lazily loading web process rejected subscriptions for actors it could
133
+ serve until some earlier request happened to load the class. The engine now
134
+ loads them in every process, and a rejected subscription reports which
135
+ condition caused it instead of closing the socket silently.
116
136
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
117
137
  distributed per-actor rate limits and global admission control do not.
118
138
  - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
@@ -129,7 +149,10 @@
129
149
  of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
130
150
  because `DeadLetterManager` exposes no bulk operation. Pause is an operator
131
151
  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
152
+ synchronous caller waiting on a paused instance times out. Retry also only
153
+ exists for message dead letters: a dead effect or broadcast has no retry
154
+ API, which matters for transmit effects because a dead one is a lost
155
+ replay until an operator returns its row to pending. The page cost was
133
156
  reasoned about rather than measured: the summary bar issues a fixed set of
134
157
  indexed aggregate queries per page, which is why `HEAD /` exists for uptime
135
158
  monitors, but no dashboard latency has been benchmarked against a large
@@ -47,6 +47,6 @@ A safe destructive rollout normally uses:
47
47
  Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
48
48
  ordering, observables, and outboxes remain intact.
49
49
 
50
- This guide covers evolution after state belongs to Solid Objects. For moving
50
+ This guide covers changes to state that Solid Objects already owns. To move
51
51
  existing Redis, key-value, or relational state into actors without downtime,
52
52
  use the [legacy-state migration cookbook](migrating-existing-state.md).
@@ -0,0 +1,198 @@
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.
62
+
63
+ Delivery is at-least-once by design, and the drain accepts redundant sends
64
+ as the price of ordering without cross-worker coordination. A drained
65
+ sibling's own effect row stays pending, because completing it would
66
+ require taking over another worker's claim; when its own claim runs, it
67
+ delivers again and the receiving side drops the replay. The same is true
68
+ when two workers claim effects for one actor concurrently. Both runtimes
69
+ share this behavior, and the Ruby suite pins it with a race test: order
70
+ holds, duplicates apply nothing, and every effect completes.
71
+
72
+ ## Retry budget and offline tolerance
73
+
74
+ A raised delivery follows the effect retry policy: `max_attempts` (default
75
+ 5) and `retry_delay` (default `2 ** (attempt - 1)` seconds, capped at 60).
76
+ The defaults give roughly fifteen seconds of offline tolerance before an
77
+ envelope dead-letters. An application that transmits across real outages
78
+ must raise both:
79
+
80
+ ```ruby
81
+ SolidObjects.configure do |configuration|
82
+ configuration.max_attempts = 30
83
+ configuration.retry_delay = ->(attempt) { [ 2**(attempt - 1), 300 ].min.to_f }
84
+ end
85
+ ```
86
+
87
+ These settings apply to every effect, not only transmits. A dead transmit
88
+ effect has no retry API; the dashboard lists it, and recovery means
89
+ returning its row to `pending` with a cleared `attempt_count`. Order
90
+ survives that recovery, because the drain orders by mailbox sequence, not
91
+ by retry time.
92
+
93
+ ## Wire contract
94
+
95
+ The JS side owns the envelope format. The Ruby ingest accepts it verbatim.
96
+
97
+ - Keys arrive camelCase: `effectId`, `actorType`, `actorId`, `operation`,
98
+ and an optional `arguments` object. There is no snake_case dialect.
99
+ - The idempotency key is `transmit:<effectId>`, byte-identical to the JS
100
+ server ingest. A replayed envelope applies once.
101
+ - Delivery is at-least-once and per-actor ordered by the browser's drain.
102
+ The server preserves mailbox order and adds no ordering of its own.
103
+
104
+ `compatibility/transmit-envelopes.json` pins the contract. Both runtimes
105
+ run a consuming test against the same fixture file.
106
+
107
+ ## What `receive` does
108
+
109
+ 1. It validates the envelope shape. A malformed envelope raises
110
+ `SolidObjects::InvalidTransmission`.
111
+ 2. It resolves the actor type and looks it up in the registry. On a miss
112
+ under Rails it loads the application's actor classes once and retries,
113
+ because a lazy-loading process has no other reason to have loaded the
114
+ target class. A type that is still unknown raises
115
+ `SolidObjects::UnknownActorType`. An undeclared operation raises
116
+ `SolidObjects::UnknownMessage`.
117
+ 3. It enqueues one internal message with the idempotency key
118
+ `transmit:<effectId>`. Oversized arguments raise
119
+ `SolidObjects::PayloadTooLarge` before persistence.
120
+
121
+ The enqueue uses `delivery_mode: "internal"`, the same mode the effect
122
+ executor uses. Internal delivery skips `authorize_message` by construction.
123
+ The host application must authenticate the request before it calls
124
+ `receive`.
125
+
126
+ ## The engine route
127
+
128
+ The engine mounts `POST /solid_objects/transmit` (under wherever the host
129
+ mounts `SolidObjects::Engine`). It parses the body, authorizes it through
130
+ `authorize_transmission`, and passes it to `Transmission.receive` with the
131
+ configured `transmission_actor_type_resolver`. The policy denies by
132
+ default, because the ingest skips `authorize_message` by design; an
133
+ unauthorized envelope gets 403, and an envelope the server can never apply
134
+ gets 422:
135
+
136
+ ```ruby
137
+ SolidObjects.configure do |configuration|
138
+ configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
139
+ ActiveSupport::SecurityUtils.secure_compare(
140
+ authorization_context.request.headers["Authorization"].to_s,
141
+ "Bearer #{Rails.application.credentials.transmit_token}"
142
+ )
143
+ end
144
+ end
145
+ ```
146
+
147
+ The policy receives the parsed envelope and the controller as
148
+ `authorization_context:`, so it can bind `actorType` and `actorId` to the
149
+ authenticated caller, not only check a shared token. Rate limits stay with
150
+ the host (Rack::Attack or the proxy), the same boundary the dashboard
151
+ draws.
152
+
153
+ ## A hand-rolled route
154
+
155
+ An application that wants its own controller keeps the same shape:
156
+
157
+ ```ruby
158
+ class TransmitController < ApplicationController
159
+ skip_forgery_protection
160
+
161
+ def create
162
+ head :forbidden and return unless authenticated_device?
163
+
164
+ SolidObjects::Transmission.receive(JSON.parse(request.body.read))
165
+ head :ok
166
+ rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownActorType,
167
+ SolidObjects::UnknownMessage, SolidObjects::PayloadTooLarge,
168
+ SolidObjects::IdempotencyConflict, JSON::ParserError
169
+ head :unprocessable_entity
170
+ end
171
+ end
172
+ ```
173
+
174
+ Return 422 for an envelope the server can never apply. The browser outbox
175
+ dead-letters that effect instead of retrying it forever. Return a 5xx for a
176
+ transient server fault, so the browser retries with backoff.
177
+ `SolidObjects::IdempotencyConflict` belongs in the 422 list: it means the
178
+ effect id was replayed with a different invocation, and no retry can ever
179
+ make that envelope apply.
180
+
181
+ ## Actor type mapping
182
+
183
+ When both runtimes use the same actor type strings, no configuration is
184
+ needed. When the names diverge, pass `resolve_actor_type:` per call:
185
+
186
+ ```ruby
187
+ SolidObjects::Transmission.receive(
188
+ envelope,
189
+ resolve_actor_type: ->(actor_type) { actor_type.sub("browser-", "server-") }
190
+ )
191
+ ```
192
+
193
+ ## Scope
194
+
195
+ Bidirectional replication as a first-class surface, where two runtimes
196
+ declare a replica pair and echo suppression keeps a replayed operation
197
+ from transmitting back, is a separate feature. The transmit family gives
198
+ it the mechanism; the declaration API does not exist yet.
@@ -0,0 +1,14 @@
1
+ # rbs_inline: enabled
2
+
3
+ class DeliveryCounter < SolidObjects::Actor
4
+ actor_type "delivery-counter"
5
+
6
+ attribute :count, default: 0
7
+
8
+ # @rbs () -> Integer
9
+ def deliver
10
+ self.count += 1
11
+ emit :record
12
+ count
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "bundler/setup"
4
+ require "active_record"
5
+ require "solid_objects"
6
+
7
+ # Boots a standalone runtime against a shared SQLite file, the way the
8
+ # demo's parent and its crashing children all attach to one database.
9
+ module AtLeastOnceBoot
10
+ ROOT = File.expand_path("../..", __dir__)
11
+
12
+ # @rbs (String database_path) -> void
13
+ def self.call(database_path)
14
+ ActiveRecord::Base.establish_connection(
15
+ adapter: "sqlite3",
16
+ database: database_path,
17
+ pool: 5,
18
+ timeout: 5_000
19
+ )
20
+ ActiveRecord::Migration.verbose = false
21
+ migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
22
+
23
+ require "solid_objects/database_adapter"
24
+ %w[
25
+ record process instance message ready_message claimed_message
26
+ reminder effect broadcast dead_letter
27
+ ].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
28
+
29
+ SolidObjects.configuration.authorize_message = ->(**) { true }
30
+ SolidObjects.configuration.authorize_query = ->(**) { true }
31
+ SolidObjects.configuration.polling_interval = 0.01
32
+ SolidObjects.configuration.process_heartbeat_interval = 0.075
33
+ SolidObjects.configuration.process_alive_threshold = 0.3
34
+ SolidObjects.configuration.lease_duration = 0.25
35
+ SolidObjects.configuration.lease_renewal_interval = 0.05
36
+ end
37
+
38
+ # @rbs () -> void
39
+ def self.migrate
40
+ require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
41
+ require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
42
+ require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
43
+ CreateSolidObjectsTables.new.migrate(:up)
44
+ AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
45
+ RenameMessageDispatchColumns.new.migrate(:up)
46
+ end
47
+ end