You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add SolidObjects::Transmission.receive(envelope): the server-side ingest
for the browser transmit family that solid-objects-js#18
ships (plan in solid-objects-js#17).
A solid-objects-js actor in a browser stages a transmit intent with this.transmit().increment({ amount }) in the same transaction as its
state change. The browser's effect worker drains that outbox with
at-least-once delivery, per-actor order, and retry backoff, and posts one
JSON envelope per effect to a route the host application owns. This
proposal gives a Rails application the receiving side, so a local-first
browser front end can replay its operations onto Ruby server actors.
Why this is small
The gem already holds every primitive the ingest needs:
Mailbox#enqueue accepts delivery_mode: and idempotency_key:, and find_idempotent_message / validate_idempotent_message! give the
dedup semantics the envelope contract needs.
delivery_mode: "internal" exists (the effect executor uses it), and
authorization lives in Client, not in Mailbox. An internal enqueue
therefore skips authorize_message by construction, which matches the
JS enqueueInternalMessage semantics exactly.
SolidObjects.registry.fetch raises UnknownActorType, and Serialization.dump enforces the payload byte cap.
The feature is one module (~40 lines), one error class, and tests.
Wire contract (owned by the JS side; must match byte for byte)
Envelope keys arrive camelCase, because a browser produced them: effectId, actorType, actorId, operation, arguments.
The Ruby ingest accepts them verbatim. No snake_case dialect.
The idempotency key is transmit:<effectId>, byte-identical to the JS
server ingest. A replayed envelope applies once.
Delivery is at-least-once and per-actor ordered by the browser's drain.
The server preserves mailbox order; it adds no ordering of its own.
A 422 tells the browser outbox to dead-letter the effect instead of
retrying it forever. Internal delivery skips authorize_message, so the
host must authenticate before the receive call.
Naming
Transmission (noun) rather than Transmit (verb), because the gem names
modules with nouns (Mailbox, Activation, Serialization, Administration) and Transmission.receive reads as noun plus verb the
way Mailbox#enqueue does. Parity is the guarantee, not the identifier;
the JS side keeps its verb family (this.transmit(), registerTransmit, TransmitEnvelope), and the wire contract above is what both runtimes
share.
Design decisions
Actor type mapping. Zero configuration when both runtimes use the
same actor type strings. resolve_actor_type: is the per-call escape
hatch for diverged names. No global configuration.
No engine route in v1. The core method ships first; an
engine-mounted POST /solid_objects/transmit with an authenticate_transmit hook is a possible follow-up, but it carries
authentication, CSRF, and rate-limit decisions that deserve their own
review.
Golden fixtures pin the wire format. A shared compatibility/transmit-envelopes.json (valid, duplicate, and
malformed cases) is committed to both repositories. The JS suite
asserts its bridge produces and accepts them; the Ruby suite asserts Transmission.receive accepts and rejects the same ones. Both repos
already have cross-runtime machinery for this (compatibility/ruby.yml
in the JS repo, test/javascript/ here).
Failing Minitest first: a valid envelope enqueues an internal message
and the actor applies it; the same envelope twice applies once;
camelCase keys accepted verbatim; malformed envelopes raise InvalidTransmission; unknown actor type and unknown operation raise;
the payload byte cap holds.
Implement lib/solid_objects/transmission.rb and the error class.
Add the golden fixtures to both repositories with a consuming test on
each side.
Documentation: a gem docs section with the controller example; a docs/roadmap.md entry here; the parity ledger row in solid-objects-js/docs/parity.md moves the server ingest to native in
both runtimes while browser staging stays JavaScript-only; both
changelogs.
Both suites green: bundle exec rake here, pnpm run check and pnpm test in the JS repository.
Open questions
Same-name actor types as the cross-runtime convention, with resolve_actor_type: as the escape hatch?
Land after solid-objects-js#18 merges (the fixture file touches both
repositories), as a paired follow-up?
InvalidTransmission or InvalidTransmitEnvelope for the error class
name?
Summary
Add
SolidObjects::Transmission.receive(envelope): the server-side ingestfor the browser transmit family that
solid-objects-js#18
ships (plan in
solid-objects-js#17).
A solid-objects-js actor in a browser stages a transmit intent with
this.transmit().increment({ amount })in the same transaction as itsstate change. The browser's effect worker drains that outbox with
at-least-once delivery, per-actor order, and retry backoff, and posts one
JSON envelope per effect to a route the host application owns. This
proposal gives a Rails application the receiving side, so a local-first
browser front end can replay its operations onto Ruby server actors.
Why this is small
The gem already holds every primitive the ingest needs:
Mailbox#enqueueacceptsdelivery_mode:andidempotency_key:, andfind_idempotent_message/validate_idempotent_message!give thededup semantics the envelope contract needs.
delivery_mode: "internal"exists (the effect executor uses it), andauthorization lives in
Client, not inMailbox. An internal enqueuetherefore skips
authorize_messageby construction, which matches theJS
enqueueInternalMessagesemantics exactly.SolidObjects.registry.fetchraisesUnknownActorType, andSerialization.dumpenforces the payload byte cap.The feature is one module (~40 lines), one error class, and tests.
Wire contract (owned by the JS side; must match byte for byte)
effectId,actorType,actorId,operation,arguments.The Ruby ingest accepts them verbatim. No snake_case dialect.
transmit:<effectId>, byte-identical to the JSserver ingest. A replayed envelope applies once.
The server preserves mailbox order; it adds no ordering of its own.
Code shape
Plus
InvalidTransmission < Errorinerrors.rbfor malformed envelopes.The host application owns HTTP and authentication, the same boundary the
gem draws for realtime transport:
A 422 tells the browser outbox to dead-letter the effect instead of
retrying it forever. Internal delivery skips
authorize_message, so thehost must authenticate before the
receivecall.Naming
Transmission(noun) rather thanTransmit(verb), because the gem namesmodules with nouns (
Mailbox,Activation,Serialization,Administration) andTransmission.receivereads as noun plus verb theway
Mailbox#enqueuedoes. Parity is the guarantee, not the identifier;the JS side keeps its verb family (
this.transmit(),registerTransmit,TransmitEnvelope), and the wire contract above is what both runtimesshare.
Design decisions
same actor type strings.
resolve_actor_type:is the per-call escapehatch for diverged names. No global configuration.
engine-mounted
POST /solid_objects/transmitwith anauthenticate_transmithook is a possible follow-up, but it carriesauthentication, CSRF, and rate-limit decisions that deserve their own
review.
compatibility/transmit-envelopes.json(valid, duplicate, andmalformed cases) is committed to both repositories. The JS suite
asserts its bridge produces and accepts them; the Ruby suite asserts
Transmission.receiveaccepts and rejects the same ones. Both reposalready have cross-runtime machinery for this (
compatibility/ruby.ymlin the JS repo,
test/javascript/here).(
Actor#transmitandregister_transmit, for Rails-to-Rails andRails-to-Node replication) is RFP: Actor#transmit and register_transmit, the staging side of the transmit family #48, which builds on this issue's
envelope validation and fixtures. This issue is ingest only.
Plan
and the actor applies it; the same envelope twice applies once;
camelCase keys accepted verbatim; malformed envelopes raise
InvalidTransmission; unknown actor type and unknown operation raise;the payload byte cap holds.
lib/solid_objects/transmission.rband the error class.each side.
docs/roadmap.mdentry here; the parity ledger row insolid-objects-js/docs/parity.mdmoves the server ingest to native inboth runtimes while browser staging stays JavaScript-only; both
changelogs.
bundle exec rakehere,pnpm run checkandpnpm testin the JS repository.Open questions
resolve_actor_type:as the escape hatch?repositories), as a paired follow-up?
InvalidTransmissionorInvalidTransmitEnvelopefor the error classname?