Repository navigation
RFP: SolidObjects::Transmission.receive, the server ingest for browser transmit envelopes #47
Description
Summary
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#enqueueacceptsdelivery_mode:andidempotency_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 inClient, not inMailbox. An internal enqueue
therefore skipsauthorize_messageby construction, which matches the
JSenqueueInternalMessagesemantics exactly.SolidObjects.registry.fetchraisesUnknownActorType, and
Serialization.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)
- 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.
Code shape
# lib/solid_objects/transmission.rb
module SolidObjects
module Transmission
REQUIRED_FIELDS = %w[effectId actorType actorId operation].freeze
def self.receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new)
validate!(envelope)
actor_type = resolve_actor_type.call(envelope["actorType"])
actor_class = SolidObjects.registry.fetch(actor_type)
operation = envelope["operation"].to_sym
unless actor_class.definition.messages.key?(operation)
raise UnknownMessage, "unknown operation #{envelope["operation"].inspect}"
end
mailbox.enqueue(
reference: Reference.new(actor_type:, actor_id: envelope["actorId"]),
operation:,
arguments: envelope["arguments"] || {},
delivery_mode: "internal",
idempotency_key: "transmit:#{envelope["effectId"]}"
)
end
end
endPlus InvalidTransmission < Error in errors.rb for malformed envelopes.
The host application owns HTTP and authentication, the same boundary the
gem draws for realtime transport:
class TransmitController < ApplicationController
skip_forgery_protection
def create
head :forbidden and return unless authenticated_device?
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
head :ok
rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownMessage, JSON::ParserError
head :unprocessable_entity
end
endA 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-mountedPOST /solid_objects/transmitwith an
authenticate_transmithook 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.receiveaccepts and rejects the same ones. Both repos
already have cross-runtime machinery for this (compatibility/ruby.yml
in the JS repo,test/javascript/here). - Out of scope here, proposed separately. The staging side in Ruby
(Actor#transmitandregister_transmit, for Rails-to-Rails and
Rails-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
- 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.rband 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.mdentry here; the parity ledger row in
solid-objects-js/docs/parity.mdmoves the server ingest to native in
both runtimes while browser staging stays JavaScript-only; both
changelogs. - Both suites green:
bundle exec rakehere,pnpm run checkand
pnpm testin 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? InvalidTransmissionorInvalidTransmitEnvelopefor the error class
name?
Activity
feat: mount an engine transmit ingest route