diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d425f5..1325032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ # Changelog -## Unreleased +## 0.4.0 - 2026-08-06 +- Add dependency-driven live ERB components with request-time authorization, + conventional partial resolution, revision fencing, refresh coalescing, and + reconnect convergence without broadcasting personalized HTML. +- Persist a monotonic state revision for secure component refresh ordering. - Retry SQLite synchronous lock contention in Ruby so a native busy wait cannot starve the thread holding the database lock. - Allow maintainers to dispatch CI manually when a push webhook is dropped. diff --git a/Gemfile.lock b/Gemfile.lock index 6d85c8b..e10ea10 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.3.0) + solid_objects (0.4.0) actioncable (>= 8.0) actionpack (>= 8.0) actionview (>= 8.0) @@ -373,7 +373,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.3.0) + solid_objects (0.4.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 diff --git a/README.md b/README.md index 1d0cfd9..8660990 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ tested, but the project does not yet claim production readiness. See - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails) - [Reactive ERB](#reactive-erb) - [Installation](#installation) +- [Upgrading](#upgrading) - [Worker requirements](#worker-requirements) - [Defining an actor](#defining-an-actor) - [Actor identity](#actor-identity) @@ -173,42 +174,105 @@ rolled-back state change cannot leak into the page. Define an observable: ```ruby -class ShoppingCart < SolidObjects::Actor - attribute :items, default: -> { [] } +class ChatRoom < SolidObjects::Actor + attribute :recent_messages, default: -> { [] } + attribute :status, default: "open" - observable :items_count do - items.sum { |item| item.fetch("quantity") } + observable :message_count do + recent_messages.length end + + observable :recent_messages + observable :status end ``` -Render it: +Scalar observables remain stable `` targets: + +```erb +<%= solid_object @room, authorization_context: current_user do |room| %> + Messages: <%= room.message_count %> +<% end %> +``` + +Reactive components rerender a host ERB partial when one of their explicit +dependencies changes: ```erb -<%= solid_object current_cart do |cart| %> - Cart items: <%= cart.items_count %> +<%= solid_object @room, authorization_context: current_user do |room| %> + <%= room.component :messages, observes: :recent_messages %> + <%= room.component :presence, observes: %i[recent_messages status] %> <% end %> ``` -That template provides initial server rendering, a stable opaque DOM target, -and live Turbo replacements after committed actor turns. One `solid_object` -block makes one Action Cable subscription for all values inside it, and Action -Cable multiplexes subscriptions over the browser's WebSocket. +`room.component(:messages)` resolves only +`actors/chat_room/_messages`. Its partial receives `actor` and +`authorization_context` locals: + +```erb + +``` + +Declared observables are deeply frozen ordinary Ruby values inside a +component. Arrays support loops, hashes support ordinary lookup, conditionals +work normally, and ERB still escapes user strings. A reactive component cannot +read `actor.state`, access an undeclared observable, or choose a dynamic +partial path. + +That template provides initial server rendering, stable opaque DOM targets, +and live updates after committed actor turns. One `solid_object` block makes +one Action Cable subscription for all scalar values and components inside it, +and Action Cable multiplexes subscriptions over the browser's WebSocket. No client-side state store, custom Stimulus controller, channel class, manual broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens -protect integrity, an application policy authorizes every subscription, -broadcasts are delivered from a durable outbox, and reconnecting clients -refresh from current actor state. +protect integrity, not access. Initial rendering authorizes with the +`authorization_context` passed to `solid_object`; Cable authorizes with its +connection; every component refresh authorizes again with a request-specific +context: -`cart.component(:summary)` supports initial rendering of -`actors/shopping_cart/_summary`. Durable live component replacement and -Turbo append actions are roadmap work; observable replacement is the live path -implemented today. +```ruby +SolidObjects.configure do |configuration| + configuration.component_authorization_context = ->(controller:) { Current.user } +end +``` + +The durable outbox stores one row per changed observable, never personalized +HTML. Cable sends invalidation metadata over the shared actor stream, then a +Turbo Frame requests the component with normal cookies. Only scalar targets +that the server rendered into this `solid_object` scope are signed into its +stream token and receive value payloads; component-only dependencies do not +send their values to the browser. The endpoint renders the latest committed +snapshot, returns `private, no-store`, and reauthorizes the component name plus +every declared dependency. Two viewers can therefore receive different HTML +for the same actor without sharing either projection. + +Reconnect compares the component's signed initial revision with the latest +actor incarnation and state revision, then refreshes stale components. Cable +coalesces several dependency changes from one actor turn into one component +refresh and ignores older out-of-order invalidations. A newer invalidation +replaces an in-flight frame, so its detached older response cannot overwrite +newer state. + +Reactive components add no HTML to durable rows, but each affected component +causes an authorized HTTP render. One actor turn still inserts one broadcast +row per changed observable; several dependencies from that turn coalesce at +the subscriber. Keep components bounded, declare only necessary dependencies, +and use scalar observables for inexpensive single-value replacement. Reactive views require `turbo-rails` and a working Action Cable adapter in the -host application. They are optional; the actor runtime itself does not depend -on Turbo. +host application. The Solid Objects engine must be mounted so its signed +component endpoint is reachable. Reactive views are optional; the actor +runtime itself does not depend on Turbo. + +```ruby +# config/routes.rb +mount SolidObjects::Engine => "/solid_objects" +``` ## Installation @@ -274,6 +338,50 @@ can generate the gem RBI with: bundle exec tapioca gem solid_objects ``` +## Upgrading + +Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order +notes, then update the gem: + +```bash +bundle update solid_objects +``` + +If the `Gemfile` pins an exact version, update that constraint first and run +`bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects +migrations. + +Copy only migrations that the newer gem has added, migrate, and verify the +installation: + +```bash +bin/rails solid_objects:install:migrations +bin/rails db:migrate +bin/rails solid_objects:doctor +``` + +The migration task skips engine migrations already present in the application +and gives new migrations host-specific timestamps. Inspect the resulting +`db/migrate/*.solid_objects.rb` files before applying them. Do not rerun +`generate solid_objects:install` during an upgrade because that also attempts +to regenerate the application initializer. + +When Solid Objects uses a separate database configuration named `actors`, copy +and run migrations through that database's configured migration path: + +```bash +DATABASE=actors bin/rails solid_objects:install:migrations +bin/rails db:migrate:actors +bin/rails solid_objects:doctor +``` + +For production, back up the actor database and run new migrations before +starting application or Solid Objects worker processes that require the new +schema. Restart the web and Solid Objects worker fleet after the bundle and +schema are current. For releases that change actor state versions, also follow +the [state migration and rolling-deployment guide](docs/state-migrations.md); +Rails schema migrations and actor state migrations are separate concerns. + ## Worker requirements Synchronous actors can be adopted without adding a long-running process. Start @@ -290,7 +398,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes: | `emit` without an actor callback | Effect worker | | `emit` with success or failure callback | Effect worker and actor worker | | Actor-to-actor `async` or `send_to` | Effect worker and actor worker | -| Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path | +| Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path | | Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering | One command starts every Solid Objects role: @@ -929,7 +1037,7 @@ See the [development guide](docs/development.md) and ## Status -Implemented and tested in 0.3: +Implemented and tested in 0.4: - Rails engine, install generator, migrations, and `solid_objects` executable; - actor registry, references, JSON state, and state migrations; @@ -947,7 +1055,8 @@ Implemented and tested in 0.3: - one-shot and recurring per-actor reminders; - authorized actor destruction with fenced stale-write rejection and cascading durable-work cleanup; -- durable observable broadcasts and authorized Action Cable refresh; +- durable observable invalidations, scalar Turbo replacement, and authorized + request-time ERB component refresh; - process registration, heartbeats, caller shutdown, cleanup, and bounded message/process retention plus opt-in actor-instance expiration; - an opt-in Minitest helper for actor-state isolation and deterministic async @@ -961,8 +1070,8 @@ Partially implemented: run periodic maintenance automatically; - cross-process wake-up uses polling; PostgreSQL notifications and optional Redis acceleration are not implemented; -- live observable replacement works, while live component replacement and - Turbo append actions remain future work; +- live observable and component replacement work, while Turbo append actions + remain future work; - local admission limits exist, but distributed rate limits and global admission control do not; and - administration views and pruning commands exist, but scheduled maintenance diff --git a/app/controllers/solid_objects/components_controller.rb b/app/controllers/solid_objects/components_controller.rb new file mode 100644 index 0000000..eb61e84 --- /dev/null +++ b/app/controllers/solid_objects/components_controller.rb @@ -0,0 +1,78 @@ +# rbs_inline: enabled + +require "action_controller/base" + +module SolidObjects + class ComponentsController < ActionController::Base + protect_from_forgery with: :exception + + # @rbs () -> void + def show + registration = ComponentRegistration.from_token( + params.require(:token) + ) + requested_revision = requested_revision_key + snapshot = ActorSnapshot.new(registration.reference) + return head :conflict if newer_than_snapshot?(requested_revision, snapshot) + + authorization_context = SolidObjects + .configuration + .component_authorization_context + .call(controller: self) + rendered = ComponentRenderer.new( + snapshot:, + component_name: registration.component_name, + dependencies: registration.dependencies, + view_context: component_view_context, + authorization_context: + ).call + response.headers["Cache-Control"] = "private, no-store" + render html: component_frame(registration, snapshot, rendered) + rescue Unauthorized + head :forbidden + rescue UnknownComponent + head :not_found + rescue ActionController::ParameterMissing, + ArgumentError, + InvalidComponentToken + head :bad_request + end + + private + + # @rbs () -> Array[Integer] + def requested_revision_key + instance_id = Integer(params.fetch(:instance_id), 10) + revision = Integer(params.fetch(:revision), 10) + raise ArgumentError if instance_id.negative? || revision.negative? + + [ instance_id, revision ] + end + + # @rbs (Array[Integer], ActorSnapshot) -> bool + def newer_than_snapshot?(requested_revision, snapshot) + (requested_revision <=> [ snapshot.instance_id, snapshot.revision ]) == 1 + end + + # @rbs () -> untyped + def component_view_context + if defined?(Rails) && Rails.application + prepend_view_path(*Rails.application.paths["app/views"].existent) + end + + view_context.tap do |context| + context.extend(Rails.application.helpers) if defined?(Rails) && Rails.application + end + end + + # @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String + def component_frame(registration, snapshot, rendered) + target = DomIdentity.component( + registration.reference, + registration.component_name + ) + revision = "#{snapshot.instance_id}:#{snapshot.revision}" + %(#{rendered}).html_safe + end + end +end diff --git a/app/helpers/solid_objects/actor_helper.rb b/app/helpers/solid_objects/actor_helper.rb index d45496b..93598f2 100644 --- a/app/helpers/solid_objects/actor_helper.rb +++ b/app/helpers/solid_objects/actor_helper.rb @@ -9,11 +9,20 @@ def solid_object(reference, authorization_context: self, &block) view_context: self, authorization_context: ) - subscription = tag.turbo_cable_stream_source( - channel: "SolidObjects::ActorChannel", - token: StreamToken.generate(reference) - ) content = capture(actor, &block) + subscription_attributes = { + channel: "SolidObjects::ActorChannel", + token: StreamToken.generate( + reference, + observables: actor.scalar_observable_names + ) + } + if actor.component_tokens.any? + subscription_attributes[:data] = { + components: JSON.generate(actor.component_tokens) + } + end + subscription = tag.turbo_cable_stream_source(**subscription_attributes) content_tag( :div, diff --git a/config/routes.rb b/config/routes.rb index c804ecf..42962e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ # rbs_inline: enabled SolidObjects::Engine.routes.draw do + get :components, to: "components#show" resources :instances, only: %i[index show] resources :dead_letters, only: %i[index] do post :retry, on: :member diff --git a/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb b/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb new file mode 100644 index 0000000..ee279c3 --- /dev/null +++ b/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb @@ -0,0 +1,12 @@ +# rbs_inline: enabled + +class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0] + # @rbs () -> void + def change + add_column SolidObjects.table_name(:instances), + :state_revision, + :bigint, + null: false, + default: 0 + end +end diff --git a/docs/architecture.md b/docs/architecture.md index 2a1c1bf..24df7aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,7 +120,11 @@ instance first prevents a claimed reminder from recreating a destroyed actor. ### Broadcast worker -The broadcast worker claims committed observable-change rows, renders idempotent Turbo replacements, broadcasts to a signed actor stream, and records delivery. Current actor state remains the reconnect source of truth. +The broadcast worker claims committed observable-change rows, renders +idempotent scalar Turbo replacements with component invalidation metadata, +broadcasts to a signed actor stream, and records delivery. It never renders +personalized component HTML. Current actor state remains the reconnect and +request-time component source of truth. ### Process registry @@ -288,13 +292,14 @@ Successful completion uses one database transaction: 3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation. 4. Execute registered same-database commit actions. 5. Update native JSON state and state version. -6. Store the completion timestamp and result on the durable message and delete claimed membership. -7. Insert staged effects. -8. Insert or update staged reminders. -9. Insert staged actor-message outbox rows. -10. Insert changed-observable broadcast rows. -11. Update actor last-used time. -12. Commit. +6. Advance the actor state revision to the completed message sequence. +7. Store the completion timestamp and result on the durable message and delete claimed membership. +8. Insert staged effects. +9. Insert or update staged reminders. +10. Insert staged actor-message outbox rows. +11. Insert changed-observable broadcast rows. +12. Update actor last-used time. +13. Commit. Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation. @@ -460,25 +465,61 @@ Large repairs use `async(..., available_at:)` to spread work over an application ## Realtime integration -`solid_object` performs an authorized state read for initial rendering and emits: +`solid_object` performs an authorized state read for initial rendering and +emits: - A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID - One Turbo Cable subscription element for the actor - Stable child target IDs for values and components - A signed actor token used by the channel subscription +- Signed component registrations containing a conventional component name, + explicit observable dependencies, the initial actor incarnation/revision, + and a same-origin engine refresh path ```erb <%= solid_object current_cart do |cart| %> Cart items: <%= cart.items_count %> - <%= cart.component :summary %> + <%= cart.component :summary, observes: %i[items checkout_status] %> <% end %> ``` The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams. -Broadcast replacements happen after the actor transaction commits because only a committed broadcast outbox row can be delivered. Multiple values share the same Action Cable connection and one actor subscription. - -Each channel subscription transmits current observable replacements before streaming future broadcasts, including after reconnect. Missing a broadcast therefore creates temporary staleness, not permanent divergence. +Scalar observable calls remain direct escaped Turbo replacements. A reactive +component resolves only `actors//_`, receives its +declared observables as frozen Ruby values, and cannot read raw state or a +dependency it did not declare. A static initial-only component can still use a +server-selected explicit partial; a reactive component cannot. + +Broadcast replacements happen after the actor transaction commits because only +a committed broadcast outbox row can be delivered. Multiple scalar values and +components share the same Action Cable connection and one actor subscription. +One outbox row still exists per changed observable. + +The shared stream contains invalidation metadata and scalar HTML only for the +scalar targets signed into that scope's stream token. Component-only +dependencies do not send their values to the browser, and the stream never +contains personalized component HTML. For each subscription, `ActorChannel` +matches the changed observable to registered component dependencies. It +coalesces multiple dependencies at the same message sequence and drops older +revision pairs. A component invalidation replaces its stable target with a +Turbo Frame whose source is the signed engine endpoint. + +The browser then makes an ordinary cookie-bearing HTTP request. The engine +controller derives a request-specific context through +`component_authorization_context`, calls `authorize_query` for the component +name and every declared dependency, renders the host partial from a new +committed snapshot, and returns `private, no-store` HTML. Subscribers to the +same actor can therefore receive different HTML without sharing it through +Cable or the database. + +Each channel subscription transmits current scalar replacements and compares +each component's signed initial revision against the latest committed +`(instance_id, state_revision)` pair, including after reconnect. Missing a +broadcast therefore creates temporary staleness, not permanent divergence. +The instance primary key distinguishes destroy-and-recreate incarnations. +Replacing the full frame on each newer invalidation detaches an older in-flight +frame, preventing its slower response from replacing the current generation. ## Authorization @@ -497,6 +538,11 @@ recognizable. No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization. +Initial component rendering, Cable subscription, and request-time component +refresh deliberately use different authorization contexts. Signed component +tokens constrain actor identity, component convention, dependencies, revision, +and same-origin refresh path but never grant access. + Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names. ## Serialization @@ -639,7 +685,7 @@ PostgreSQL transaction-level advisory locks may be used for optional singleton m | Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts | | Claim activation | Backend claim transaction, generation increment, owner and expiry | | Claim next message | Move ready membership to claimed membership conditioned on lease | -| Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts | +| Successful message commit | Fenced state and monotonic revision, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts | | Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter | | Renew or release lease | Conditional instance update | | Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes | @@ -687,7 +733,7 @@ All backends use unique identity and sequence constraints, short transactions, a 19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit. 20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers. 21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream. -22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery. +22. **How are lost broadcasts recovered?** Current-state scalar replacement and authorized component refresh after reconnect; durable outbox retries server delivery. 23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles. 24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction. 25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests. diff --git a/docs/authorization.md b/docs/authorization.md index 2a7b27d..4ac0f5a 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -10,7 +10,7 @@ intentionally inert until the host application defines its trust boundary. | Policy | Gates | Caller context | Risk if opened globally | | --- | --- | --- | --- | | `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity | -| `authorize_query` | Attribute reads, declared queries, committed snapshots, observable reads, and component reads | Explicit call context or the Rails view context supplied by `solid_object` | Actor state can leak across users or tenants | +| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants | | `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted | | `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors | | `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable | @@ -20,6 +20,43 @@ invocation as a message or query. Internal reminder, effect-callback, and actor-to-actor deliveries come from already committed runtime rows and do not re-enter the public client policy. +## Realtime authorization contexts + +Reactive components cross three Rails execution contexts and authorize at all +three boundaries: + +1. `solid_object(..., authorization_context:)` uses the explicit Action View + render context for initial scalar and component reads. +2. `ActorChannel` passes its authenticated `ActionCable::Connection` to + `authorize_subscription`. +3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP + request and calls `authorize_query` for the component name and every + dependency. + +Configure the refresh resolver when the query policy expects a user or service +principal rather than the engine controller: + +```ruby +SolidObjects.configure do |configuration| + configuration.component_authorization_context = lambda do |controller:| + Current.user + end +end +``` + +Authentication middleware must populate `Current.user` for the refresh +request. Do not copy an Action View object or Cable connection into the signed +token. Those objects are request-specific and the token provides integrity, +not authorization. + +Component partials receive the resolved value as the +`authorization_context` local, allowing two authorized viewers to render +different projections. Responses use `Cache-Control: private, no-store`. +Durable outbox rows and shared Cable messages never contain component HTML. +The stream token also signs the scalar observable targets rendered into that +specific scope. Component-only dependencies send invalidation metadata but not +their state value to the browser. + ## A tenant-aware policy Pass the authenticated user as the call context: diff --git a/docs/correctness.md b/docs/correctness.md index 6b2e852..b408ebf 100644 --- a/docs/correctness.md +++ b/docs/correctness.md @@ -101,8 +101,8 @@ The following are atomic: - activation owner, expiration, and generation acquisition; - ready-to-claimed membership move and attempt increment; - state, state version, message result/completion, claimed deletion, effects, - same-database commit actions, reminders, outbound actor messages, and - observable broadcasts; + same-database commit actions, reminders, outbound actor messages, observable + broadcasts, and the monotonic actor state revision; - failed-attempt record plus ready reinsertion or dead letter; - effect completion plus its optional actor outcome message; - reminder occurrence enqueue plus reminder advancement; and @@ -116,6 +116,32 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one connection pool. Commit actions must contain only bounded database work. External I/O belongs in the effect outbox. +## Reactive components + +A successful fenced turn advances `instances.state_revision` to that message's +per-actor sequence and inserts changed-observable broadcast rows in the same +transaction. A rollback, retryable handler failure, or lost activation advances +neither. Component HTML is not durable and is never placed on the shared actor +stream. + +Each component token signs the actor identity, conventional component name, +explicit dependencies, initial instance ID and revision, and same-origin +refresh path. The signature detects modification but grants no access. Initial +rendering invokes query authorization, Cable separately invokes subscription +authorization, and the cookie-bearing refresh request invokes query +authorization again for the component name and every dependency. + +The actor stream token separately signs the scalar observable targets rendered +into its scope. A component dependency that has no scalar target carries only +its name and revision over Cable, not its serialized value. + +Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies +changed by the same turn, and ignores an older pair after a newer one. A new +invalidation replaces the whole Turbo Frame generation. A response owned by +the detached older frame cannot overwrite the current frame. Reconnect +compares the component's signed initial pair with the current instance row and +requests the latest committed snapshot when stale. + ## Synchronous invocation A direct reference method or explicit `sync` call durably enqueues an ordinary diff --git a/docs/database-schema.md b/docs/database-schema.md index 4f93adf..960e89a 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -8,9 +8,15 @@ migrations load. No partial indexes are used. ### `instances` One row per `(actor_type, actor_id)`. Stores JSON state, state version, -next-message sequence, activation owner/token/expiration/generation, pause -state, and lifecycle timestamps. The owner/token pairing is constrained so one -process row cannot make two concurrent activations appear identical. +monotonic state revision, next-message sequence, activation +owner/token/expiration/generation, pause state, and lifecycle timestamps. The +owner/token pairing is constrained so one process row cannot make two +concurrent activations appear identical. + +`state_revision` advances to the successful message sequence in the same +fenced transaction as state and outboxes. Reactive components compare +`(instance_id, state_revision)` so pruned message history cannot make revisions +regress and destroy-and-recreate produces a new incarnation. Deleting an instance is the actor-incarnation boundary. Foreign keys cascade the delete through messages, ready and claimed memberships, reminders, effects, @@ -85,8 +91,10 @@ Status/availability/ID drives delivery; completion/ID drives cleanup. ### `broadcasts` Durable observable-change outbox. The unique message/observable key prevents -duplicate rows for one actor turn. Claim and delivery indexes support retries -and cleanup. +duplicate rows for one actor turn. Rows contain the observable JSON value and +message/instance references used to derive invalidation metadata, never +personalized rendered HTML. Claim and delivery indexes support retries and +cleanup. ### `dead_letters` diff --git a/docs/realtime.md b/docs/realtime.md index 1a1df08..0440145 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -1,13 +1,15 @@ # Realtime integration -## Rendering +## Scalars and components `solid_object` performs initial server rendering and emits one `turbo-cable-stream-source` for the actor: ```erb -<%= solid_object ShoppingCartActor.ref(current_user.id) do |cart| %> +<%= solid_object ShoppingCartActor.ref(current_user.id), + authorization_context: current_user do |cart| %> Items: <%= cart.items_count %> + <%= cart.component :summary, observes: %i[items checkout_status] %> <% end %> ``` @@ -15,9 +17,39 @@ Every observable gets a stable opaque DOM ID. Multiple values share the one actor subscription and Action Cable multiplexes actor subscriptions over the browser's physical WebSocket. -`actor.component(:summary)` renders a host partial by convention at -`actors//_summary`. Initial component rendering is implemented; -durable background component replacement is not yet implemented. +Scalar observable calls such as `cart.items_count` render stable `` +targets. Their broadcast remains a direct escaped text replacement. + +A reactive component declares one or more explicit observable dependencies. +`actor.component(:summary, observes: ...)` resolves the host partial by +convention at `actors//_summary` and wraps it in a stable Turbo +Frame. Reactive components do not accept `partial:` because a client must +never influence partial resolution. The older +`actor.component(:summary, partial: "server/chosen/path")` form remains +available for initial-only static rendering. + +The partial receives exactly two component locals: + +- `actor`, which exposes the declared observables as deeply frozen ordinary + Ruby values plus `actor_id` and `reference`; and +- `authorization_context`, the context for this initial render or refresh. + +`actor.state` is unavailable in reactive components. Reading an observable not +listed in `observes:` raises `UnknownComponentDependency`. This keeps +invalidation correct and prevents a partial from silently depending on state +that cannot wake it. + +```erb +
    + <% actor.recent_messages.each do |message| %> +
  • <%= message.fetch("body") %>
  • + <% end %> +
+``` + +Arrays, hashes, loops, conditionals, nested markup, and host helper output are +normal ERB. Escaping remains Action View's responsibility; Solid Objects never +marks actor strings as HTML safe. ## Authorization @@ -26,18 +58,69 @@ it does not grant access. `ActorChannel` verifies the token, resolves the actor through the registry, calls `authorize_subscription`, and streams only after approval. -Initial observable method reads separately call `authorize_query`. Never -authorize solely from actor ID, token possession, stream name, or DOM ID. +Initial scalar and component reads call `authorize_query` with the context +passed to `solid_object`. The refresh controller resolves a new request context +through `component_authorization_context`, then calls `authorize_query` again +for the component name and every declared dependency. The default resolver +supplies the engine controller; applications commonly resolve it to +`Current.user`: + +```ruby +configuration.component_authorization_context = ->(controller:) { Current.user } +``` + +The three contexts are intentionally different: + +| Boundary | Authorization context | +| --- | --- | +| Initial Action View render | Explicit `authorization_context:` passed to `solid_object` | +| Action Cable subscription | The authenticated Cable connection | +| Component refresh | Value returned by `component_authorization_context` for the engine controller request | + +Do not substitute a signed token for any of them. Never authorize solely from +actor ID, token possession, stream name, component name, or DOM ID. ## Broadcast durability The actor's fenced commit compares observables before and after the turn and -inserts one broadcast row per changed value. A broadcast process later sends a -Turbo replacement and records delivery. No direct broadcast occurs inside the -actor transaction. +inserts one broadcast row per changed value. The actor state, monotonic +`state_revision`, message completion, and broadcast rows commit atomically. A +rolled-back or fenced-out turn therefore cannot invalidate a component. + +A broadcast process later sends small invalidation metadata and records +delivery. Its scalar Turbo replacement is transmitted only when that observable +target was rendered and signed into the scope's stream token. Component-only +dependencies therefore do not expose their serialized values over Cable. The +runtime never stores or broadcasts personalized component HTML. Each +authorized browser requests affected components through the engine endpoint +with its normal cookies. Responses are `private, no-store`. + +Several changed dependencies from one message sequence produce one logical +refresh for a component. An unrelated observable does not refresh it. If a +newer invalidation arrives while a Turbo Frame request is in flight, the new +frame replaces the old frame element; the detached older response has no +current target. If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements -from current actor state. The durable state row remains source of truth. +from current actor state. It compares the signed component revision with the +latest `(instance_id, state_revision)` pair and refreshes stale components. +The incarnation ID handles destroy-and-recreate; the state revision handles +ordered commits within one incarnation. Out-of-order invalidations at or below +the last transmitted pair are ignored. The durable state row remains source of +truth. + +The component endpoint rejects a requested revision newer than the committed +snapshot. This is a final server-side guard; browser safety primarily comes +from monotonic channel filtering and replacing the entire Turbo Frame +generation. + +## Cost model + +The durable row cost is unchanged: one broadcast row per changed observable, +containing its JSON value and the message/instance references needed to derive +invalidation metadata. No rendered document is stored. Each affected component +adds one authorized GET and one partial render per non-coalesced state +revision. Scalar observables remain the cheaper path for one text value. ## Deployment @@ -48,4 +131,15 @@ require Redis. Changing or removing observable names during a rolling deploy can strand old broadcast rows or old DOM targets. Keep old names compatible until the outbox -and old pages have drained. +and old pages have drained. Keep component partial names and dependency +observables compatible across a rolling deploy for the same reason. + +Reactive components require the engine mount because their signed refresh path +is generated from that mount. Applications with more than one engine mount can +set `component_path_resolver` to return the intended same-origin +`components_path`. + +```ruby +# config/routes.rb +mount SolidObjects::Engine => "/solid_objects" +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 2781c64..da8cbf1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,8 @@ - Transactional effects with success/failure actor messages - Actor-to-actor asynchronous outbox delivery - One-shot and recurring reminders with `:latest` or `:all` catch-up -- Durable observable broadcast outbox and authorized Action Cable refresh +- Durable observable invalidations, scalar Turbo replacement, and authorized + request-time ERB component refresh - Reconciliation read APIs - Installation doctor, authorization reference, fit guide, and legacy-state migration cookbook @@ -35,8 +36,9 @@ role or run periodic maintenance automatically. - Wake-up strategy: in-process signaling plus durable polling and injection are implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not. -- Realtime: observable replacement and reconnect refresh are implemented; - durable component replacement and Turbo append actions are not. +- Realtime: scalar and dependency-driven ERB component replacement, + personalized refresh authorization, revision fencing, coalescing, and + reconnect convergence are implemented; Turbo append actions are not. - Backpressure: mailbox/payload/state/result caps and fair yields exist; distributed per-actor rate limits and global admission control do not. - Administration: actor and dead-letter views plus policy hooks exist; richer @@ -52,8 +54,7 @@ 3. Add result lookup by request ID and broader deadlock retry classification. 4. Add scheduled retention and stale-process maintenance. 5. Add database/server-version checks and MySQL InnoDB verification at boot. -6. Add component broadcast rendering, Turbo append intents, and reconnect tests - in a full browser. +6. Add Turbo append intents and expand reconnect coverage in a full browser. 7. Add distributed rate limits, global admission hooks, and cache-capacity eviction. 8. Expand security scanning and run compatibility CI across supported Rails and diff --git a/examples/application/README.md b/examples/application/README.md index 24521c8..bff16dd 100644 --- a/examples/application/README.md +++ b/examples/application/README.md @@ -10,7 +10,8 @@ key. The chat actor also gives every submitted chat message a caller-generated message ID and checks that ID in durable actor state, because actor handlers may be redelivered. -The views demonstrate initial `solid_object` rendering and live observable -replacement. Live cart-summary replacement and chat-message append are -deliberately not shown as working behavior because component broadcasts and -Turbo append intents remain roadmap items. +The views demonstrate scalar observable replacement and a live chat-message +ERB component. The chat component receives `recent_messages` as an ordinary +Ruby array, rerenders its `
    ` after committed changes, and refreshes through +the authenticated host request context. Turbo append intents remain roadmap +work. diff --git a/examples/application/app/views/actors/chat_room_actor/_messages.html.erb b/examples/application/app/views/actors/chat_room_actor/_messages.html.erb index 2e6de1a..558c6f5 100644 --- a/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +++ b/examples/application/app/views/actors/chat_room_actor/_messages.html.erb @@ -1,5 +1,5 @@
      - <% actor.state.recent_messages.each do |message| %> + <% actor.recent_messages.each do |message| %>
    1. "> <%= message.fetch("user_id") %> <%= message.fetch("body") %> diff --git a/examples/application/app/views/chat_rooms/show.html.erb b/examples/application/app/views/chat_rooms/show.html.erb index fabf64a..362ed07 100644 --- a/examples/application/app/views/chat_rooms/show.html.erb +++ b/examples/application/app/views/chat_rooms/show.html.erb @@ -1,8 +1,8 @@ -<%= solid_object @room do |room| %> +<%= solid_object @room, authorization_context: current_user do |room| %>

      Present: <%= room.presence %>

      - <%= room.component :messages %> + <%= room.component :messages, observes: :recent_messages %> <% end %> diff --git a/examples/application/config/initializers/solid_objects.rb b/examples/application/config/initializers/solid_objects.rb index c75bce4..cd4711c 100644 --- a/examples/application/config/initializers/solid_objects.rb +++ b/examples/application/config/initializers/solid_objects.rb @@ -2,16 +2,22 @@ SolidObjects.configure do |configuration| configuration.authorize_message = lambda do |actor_type:, actor_id:, authorization_context:, **| - next false unless authorization_context.respond_to?(:current_user) + user = if authorization_context.respond_to?(:current_user) + authorization_context.current_user + else + authorization_context + end + next false unless user if actor_type == ShoppingCartActor.actor_type - authorization_context.current_user.id.to_s == actor_id + user.id.to_s == actor_id else - ChatRoomPolicy.new(authorization_context.current_user).access?(actor_id) + ChatRoomPolicy.new(user).access?(actor_id) end end configuration.authorize_query = configuration.authorize_message configuration.authorize_subscription = configuration.authorize_message + configuration.component_authorization_context = ->(controller:) { Current.user } end SolidObjects.register_effect(:charge_payment) do |arguments, context| diff --git a/lib/generators/solid_objects/templates/solid_objects.rb b/lib/generators/solid_objects/templates/solid_objects.rb index 0899f6b..085e1cc 100644 --- a/lib/generators/solid_objects/templates/solid_objects.rb +++ b/lib/generators/solid_objects/templates/solid_objects.rb @@ -49,6 +49,9 @@ configuration.authorize_subscription = ->(**) { false } configuration.authorize_administration = ->(**) { false } + # Configure component_authorization_context to return the authenticated + # principal used for reactive component refreshes. + # On hosts where shell access is already an authenticated administrative # boundary, this enables only gem commands that pass the CLI context: # diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index fb6fb80..d00fd9d 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -32,8 +32,14 @@ require "solid_objects/stream_name" require "solid_objects/dom_identity" require "solid_objects/stream_token" +require "solid_objects/component_token" +require "solid_objects/component_path_resolver" require "solid_objects/turbo_stream_renderer" require "solid_objects/actor_snapshot" +require "solid_objects/component_registration" +require "solid_objects/component_subscriptions" +require "solid_objects/component_view" +require "solid_objects/component_renderer" require "solid_objects/state_snapshot" require "solid_objects/actor_view" require "solid_objects/action_cable_broadcast_adapter" diff --git a/lib/solid_objects/actor.rb b/lib/solid_objects/actor.rb index 6603a41..7ccc015 100644 --- a/lib/solid_objects/actor.rb +++ b/lib/solid_objects/actor.rb @@ -238,6 +238,17 @@ def observable_values end end + # @rbs (Symbol | String) -> untyped + def observable_value(name) + observable_name = name.to_sym + handler = self.class.definition.observables[observable_name] + raise UnknownMessage, "unknown observable #{name.inspect}" unless handler + + guard_application_writes("observable.#{observable_name}") do + Serialization.dump(instance_exec(&handler.block)) + end + end + # @rbs () -> void def activate guard_application_writes("on_activate") do diff --git a/lib/solid_objects/actor_channel.rb b/lib/solid_objects/actor_channel.rb index 69f6d9b..520a94d 100644 --- a/lib/solid_objects/actor_channel.rb +++ b/lib/solid_objects/actor_channel.rb @@ -17,13 +17,81 @@ def subscribed ) return reject unless authorized - reference = Reference.new(actor_type:, actor_id:) - stream_from StreamName.for(reference) - ActorSnapshot.new(reference).observable_values.each do |name, value| - transmit TurboStreamRenderer.observable_value(reference, name, value) + @reference = Reference.new(actor_type:, actor_id:) + @scalar_observables = identity["observables"] + validate_scalar_observables! + @component_subscriptions = ComponentSubscriptions.parse( + params["components"], + reference: + ) + stream_from StreamName.for(reference) do |stream| + receive_broadcast(stream) + end + snapshot = ActorSnapshot.new(reference) + scalar_observable_names(snapshot).each do |name| + transmit TurboStreamRenderer.observable_value( + reference, + name, + snapshot.observable_value(name) + ) end - rescue KeyError, InvalidStreamToken, UnknownActorType + refresh_outdated_components(snapshot) + rescue KeyError, + JSON::ParserError, + InvalidStreamToken, + InvalidComponentToken, + UnknownActorType reject end + + private + + attr_reader :reference, :component_subscriptions, :scalar_observables + + # @rbs (String) -> void + def receive_broadcast(stream) + invalidation = TurboStreamRenderer.invalidation(stream) + if !invalidation || + scalar_observables.nil? || + scalar_observables.include?(invalidation.fetch("observable_name")) + transmit stream + end + return unless invalidation + + component_subscriptions + .refreshes_for(invalidation) + .each { |refresh| transmit refresh } + end + + # @rbs (ActorSnapshot) -> void + def refresh_outdated_components(snapshot) + component_subscriptions + .reconnect_refreshes(snapshot) + .each { |refresh| transmit refresh } + end + + # @rbs () -> void + def validate_scalar_observables! + return unless scalar_observables + + observables = SolidObjects + .registry + .fetch(reference.actor_type) + .definition + .observables + unknown = scalar_observables.find do |name| + !observables.key?(name.to_sym) + end + return unless unknown + + raise InvalidStreamToken, "unknown scalar observable #{unknown.inspect}" + end + + # @rbs (ActorSnapshot) -> Array[String] + def scalar_observable_names(snapshot) + return scalar_observables if scalar_observables + + snapshot.actor_class.definition.observables.keys.map(&:to_s) + end end end diff --git a/lib/solid_objects/actor_snapshot.rb b/lib/solid_objects/actor_snapshot.rb index ba7b4a5..43fa307 100644 --- a/lib/solid_objects/actor_snapshot.rb +++ b/lib/solid_objects/actor_snapshot.rb @@ -5,29 +5,48 @@ class ActorSnapshot # @rbs @reference: Reference # @rbs @actor_class: Class # @rbs @actor: Actor + # @rbs @instance_id: Integer + # @rbs @revision: Integer + # @rbs @observable_values: Hash[String, untyped]? + # @rbs @observable_value_cache: Hash[String, untyped] - attr_reader :reference, :actor_class, :actor + attr_reader :reference, :actor_class, :actor, :instance_id, :revision # @rbs (Reference) -> void def initialize(reference) @reference = reference @actor_class = SolidObjects.registry.fetch(reference.actor_type) + @instance = Instance.find_by( + actor_type: reference.actor_type, + actor_id: reference.actor_id + ) + @instance_id = @instance&.id || 0 + @revision = @instance&.state_revision || 0 @actor = build_actor + @observable_values = nil + @observable_value_cache = {} end # @rbs () -> Hash[String, untyped] def observable_values - actor.observable_values + @observable_values ||= Serialization.readonly_copy(actor.observable_values) + end + + # @rbs (Symbol | String) -> untyped + def observable_value(name) + observable_name = name.to_s + return @observable_value_cache.fetch(observable_name) if @observable_value_cache.key?(observable_name) + + @observable_value_cache[observable_name] = + Serialization.readonly_copy(actor.observable_value(observable_name)) end private + attr_reader :instance + # @rbs () -> Actor def build_actor - instance = Instance.find_by( - actor_type: reference.actor_type, - actor_id: reference.actor_id - ) state_version = instance&.state_version || actor_class.state_version state_data = ApplicationWriteGuard.call( actor_type: reference.actor_type, diff --git a/lib/solid_objects/actor_view.rb b/lib/solid_objects/actor_view.rb index b852756..d91edb2 100644 --- a/lib/solid_objects/actor_view.rb +++ b/lib/solid_objects/actor_view.rb @@ -6,6 +6,8 @@ class ActorView # @rbs @view_context: untyped # @rbs @authorization_context: untyped # @rbs @snapshot: ActorSnapshot + # @rbs @component_registrations: Array[ComponentRegistration] + # @rbs @observable_names: Array[String] attr_reader :reference @@ -15,6 +17,8 @@ def initialize(reference:, view_context:, authorization_context:) @view_context = view_context @authorization_context = authorization_context @snapshot = ActorSnapshot.new(reference) + @component_registrations = [] + @observable_names = [] end # @rbs (Symbol | String) -> untyped @@ -24,7 +28,8 @@ def value(name) raise UnknownMessage, "unknown observable #{name.inspect}" unless handler authorize_read!(observable_name) - value = snapshot.observable_values.fetch(observable_name.to_s) + value = snapshot.observable_value(observable_name) + observable_names << observable_name.to_s unless observable_names.include?(observable_name.to_s) view_context.content_tag( :span, display_value(value), @@ -32,21 +37,54 @@ def value(name) ) end - # @rbs (Symbol | String, ?partial: String?) -> untyped - def component(name, partial: nil) + # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped + def component(name, observes: nil, partial: nil) component_name = normalized_component_name(name) - authorize_read!(component_name) - rendered = view_context.render( - partial: partial || default_partial(component_name), - locals: { actor: self } + return static_component(component_name, partial:) unless observes + if partial + raise ArgumentError, + "reactive components resolve partials by actor and component name" + end + + dependencies = normalized_dependencies(observes) + validate_dependencies!(dependencies) + ensure_unique_component!(component_name) + refresh_path = component_path_resolver.call(view_context:) + registration = ComponentRegistration.issue( + reference:, + component_name:, + dependencies:, + snapshot:, + refresh_path: ) + rendered = ComponentRenderer.new( + snapshot:, + component_name:, + dependencies:, + view_context:, + authorization_context: + ).call + component_registrations << registration view_context.content_tag( - :div, + :"turbo-frame", rendered, - id: DomIdentity.component(reference, component_name) + id: DomIdentity.component(reference, component_name), + data: { + solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}" + } ) end + # @rbs () -> Array[String] + def component_tokens + component_registrations.map(&:token) + end + + # @rbs () -> Array[String] + def scalar_observable_names + observable_names.dup + end + # @rbs () -> State def state snapshot.actor.state @@ -66,7 +104,25 @@ def respond_to_missing?(name, include_private = false) private - attr_reader :view_context, :authorization_context, :snapshot + attr_reader :view_context, + :authorization_context, + :snapshot, + :component_registrations, + :observable_names + + # @rbs (String, partial: String?) -> untyped + def static_component(component_name, partial:) + authorize_read!(component_name) + rendered = view_context.render( + partial: partial || default_partial(component_name), + locals: { actor: self } + ) + view_context.content_tag( + :div, + rendered, + id: DomIdentity.component(reference, component_name) + ) + end # @rbs (Symbol | String) -> void def authorize_read!(name) @@ -106,6 +162,43 @@ def normalized_component_name(name) component_name end + # @rbs (Symbol | String | Array[Symbol | String]) -> Array[String] + def normalized_dependencies(observes) + dependencies = Array(observes).map(&:to_s).uniq + if dependencies.empty? + raise ArgumentError, "reactive components require at least one observable" + end + + dependencies + end + + # @rbs (Array[String]) -> void + def validate_dependencies!(dependencies) + unknown = dependencies.find do |dependency| + !snapshot.actor_class.definition.observables.key?(dependency.to_sym) + end + return unless unknown + + raise UnknownComponentDependency, + "unknown observable dependency #{unknown.inspect} for #{reference.actor_type}" + end + + # @rbs (String) -> void + def ensure_unique_component!(component_name) + return unless component_registrations.any? do |registration| + registration.component_name == component_name + end + + raise ArgumentError, + "component #{component_name.inspect} is already rendered in this solid_object scope" + end + + # @rbs () -> Proc | ComponentPathResolver + def component_path_resolver + SolidObjects.configuration.component_path_resolver || + ComponentPathResolver.new + end + # @rbs (String) -> String def default_partial(component_name) actor_name = snapshot.actor_class.name diff --git a/lib/solid_objects/component_path_resolver.rb b/lib/solid_objects/component_path_resolver.rb new file mode 100644 index 0000000..7e0a669 --- /dev/null +++ b/lib/solid_objects/component_path_resolver.rb @@ -0,0 +1,27 @@ +# rbs_inline: enabled + +module SolidObjects + class ComponentPathResolver + # @rbs (view_context: untyped) -> String + def call(view_context:) + unless mounted? + raise UnsupportedComponentRendering, + "reactive components require the SolidObjects::Engine to be mounted in the host routes" + end + + SolidObjects::Engine.routes.url_helpers.components_path + end + + private + + # @rbs () -> bool + def mounted? + return false unless defined?(Rails) && Rails.application + + Rails.application.routes.routes.any? do |route| + route.app.respond_to?(:app) && + route.app.app.equal?(SolidObjects::Engine) + end + end + end +end diff --git a/lib/solid_objects/component_registration.rb b/lib/solid_objects/component_registration.rb new file mode 100644 index 0000000..295e81c --- /dev/null +++ b/lib/solid_objects/component_registration.rb @@ -0,0 +1,117 @@ +# rbs_inline: enabled + +require "uri" + +module SolidObjects + class ComponentRegistration + # @rbs @reference: Reference + # @rbs @component_name: String + # @rbs @dependencies: Array[String] + # @rbs @instance_id: Integer + # @rbs @revision: Integer + # @rbs @refresh_path: String + # @rbs @token: String + + attr_reader :reference, + :component_name, + :dependencies, + :instance_id, + :revision, + :refresh_path, + :token + + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void + def initialize( + reference:, + component_name:, + dependencies:, + instance_id:, + revision:, + refresh_path:, + token: + ) + @reference = reference + @component_name = component_name + @dependencies = dependencies.freeze + @instance_id = instance_id + @revision = revision + @refresh_path = refresh_path + @token = token + end + + class << self + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration + def issue(reference:, component_name:, dependencies:, snapshot:, refresh_path:) + token = ComponentToken.generate( + reference:, + component_name:, + dependencies:, + instance_id: snapshot.instance_id, + revision: snapshot.revision, + refresh_path: + ) + new( + reference:, + component_name:, + dependencies:, + instance_id: snapshot.instance_id, + revision: snapshot.revision, + refresh_path:, + token: + ) + end + + # @rbs (String) -> ComponentRegistration + def from_token(token) + payload = ComponentToken.verify(token) + reference = Reference.new( + actor_type: payload.fetch("actor_type"), + actor_id: payload.fetch("actor_id") + ) + dependencies = payload.fetch("dependencies") + validate_dependencies!(reference, dependencies) + new( + reference:, + component_name: payload.fetch("component_name"), + dependencies:, + instance_id: payload.fetch("instance_id"), + revision: payload.fetch("revision"), + refresh_path: payload.fetch("refresh_path"), + token: + ) + rescue UnknownActorType, UnknownComponentDependency => error + raise InvalidComponentToken, error.message + end + + private + + # @rbs (Reference, Array[String]) -> void + def validate_dependencies!(reference, dependencies) + actor_class = SolidObjects.registry.fetch(reference.actor_type) + observables = actor_class.definition.observables + unknown = dependencies.find do |dependency| + !observables.key?(dependency.to_sym) + end + return unless unknown + + raise UnknownComponentDependency, + "unknown observable dependency #{unknown.inspect} for #{reference.actor_type}" + end + end + + # @rbs () -> Array[Integer] + def revision_key + [ instance_id, revision ] + end + + # @rbs (Integer, Integer) -> String + def refresh_url(instance_id, revision) + query = URI.encode_www_form( + token:, + instance_id:, + revision: + ) + "#{refresh_path}?#{query}" + end + end +end diff --git a/lib/solid_objects/component_renderer.rb b/lib/solid_objects/component_renderer.rb new file mode 100644 index 0000000..acd3ff4 --- /dev/null +++ b/lib/solid_objects/component_renderer.rb @@ -0,0 +1,82 @@ +# rbs_inline: enabled + +module SolidObjects + class ComponentRenderer + # @rbs @snapshot: ActorSnapshot + # @rbs @component_name: String + # @rbs @dependencies: Array[String] + # @rbs @view_context: untyped + # @rbs @authorization_context: untyped + + # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void + def initialize( + snapshot:, + component_name:, + dependencies:, + view_context:, + authorization_context: + ) + @snapshot = snapshot + @component_name = component_name + @dependencies = dependencies + @view_context = view_context + @authorization_context = authorization_context + end + + # @rbs () -> untyped + def call + [ component_name, *dependencies ].uniq.each do |authorization_name| + authorize_read!(authorization_name) + end + actor = ComponentView.new( + snapshot:, + dependencies:, + authorization_context: + ) + view_context.render( + partial: default_partial, + locals: { + actor:, + authorization_context: + } + ) + rescue ActionView::MissingTemplate + raise UnknownComponent, + "unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}" + rescue ActionView::Template::Error => error + raise error.cause if error.cause.is_a?(SolidObjects::Error) + + raise + end + + private + + attr_reader :snapshot, + :component_name, + :dependencies, + :view_context, + :authorization_context + + # @rbs (String) -> void + def authorize_read!(observable_name) + authorized = SolidObjects.configuration.authorize_query.call( + actor_type: snapshot.reference.actor_type, + actor_id: snapshot.reference.actor_id, + message_name: observable_name, + arguments: {}, + authorization_context: + ) + return if authorized + + raise Unauthorized, "actor component query is not authorized" + end + + # @rbs () -> String + def default_partial + actor_name = snapshot.actor_class.name + raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name + + "actors/#{actor_name.underscore}/#{component_name}" + end + end +end diff --git a/lib/solid_objects/component_subscriptions.rb b/lib/solid_objects/component_subscriptions.rb new file mode 100644 index 0000000..6f8956e --- /dev/null +++ b/lib/solid_objects/component_subscriptions.rb @@ -0,0 +1,109 @@ +# rbs_inline: enabled + +module SolidObjects + class ComponentSubscriptions + MAXIMUM_COMPONENTS = 50 + MAXIMUM_SERIALIZED_BYTES = 1_048_576 + + # @rbs @registrations: Array[ComponentRegistration] + # @rbs @revisions: Hash[String, Array[Integer]] + + # @rbs (String?, reference: Reference) -> ComponentSubscriptions + def self.parse(serialized, reference:) + return new([]) unless serialized + unless serialized.is_a?(String) && + serialized.bytesize <= MAXIMUM_SERIALIZED_BYTES + raise InvalidComponentToken, "invalid actor component registrations" + end + + tokens = JSON.parse(serialized) + unless tokens.is_a?(Array) && + tokens.length <= MAXIMUM_COMPONENTS && + tokens.all? { |token| token.is_a?(String) } + raise InvalidComponentToken, "invalid actor component registrations" + end + + registrations = tokens.map do |token| + ComponentRegistration.from_token(token).tap do |registration| + validate_identity!(registration, reference) + end + end + if registrations.map(&:component_name).uniq.length != registrations.length + raise InvalidComponentToken, "duplicate actor component registration" + end + + new(registrations) + end + + # @rbs (Array[ComponentRegistration]) -> void + def initialize(registrations) + @registrations = registrations + @revisions = registrations.to_h do |registration| + [ registration.component_name, registration.revision_key ] + end + end + + # @rbs (Hash[String, untyped]) -> Array[String] + def refreshes_for(invalidation) + observable_name = invalidation.fetch("observable_name") + instance_id = invalidation.fetch("instance_id") + revision = invalidation.fetch("revision") + registrations.filter_map do |registration| + next unless registration.dependencies.include?(observable_name) + next unless newer_revision?( + registration.component_name, + instance_id, + revision + ) + + refresh(registration, instance_id, revision) + end + end + + # @rbs (ActorSnapshot) -> Array[String] + def reconnect_refreshes(snapshot) + registrations.filter_map do |registration| + next unless newer_revision?( + registration.component_name, + snapshot.instance_id, + snapshot.revision + ) + + refresh(registration, snapshot.instance_id, snapshot.revision) + end + end + + class << self + private + + # @rbs (ComponentRegistration, Reference) -> void + def validate_identity!(registration, reference) + return if registration.reference.actor_type == reference.actor_type && + registration.reference.actor_id == reference.actor_id + + raise InvalidComponentToken, + "actor component identity does not match its subscription" + end + end + + private + + attr_reader :registrations, :revisions + + # @rbs (ComponentRegistration, Integer, Integer) -> String + def refresh(registration, instance_id, revision) + revisions[registration.component_name] = [ instance_id, revision ] + TurboStreamRenderer.component_refresh( + registration, + instance_id, + revision + ) + end + + # @rbs (String, Integer, Integer) -> bool + def newer_revision?(component_name, instance_id, revision) + current = revisions.fetch(component_name) + (current <=> [ instance_id, revision ]) == -1 + end + end +end diff --git a/lib/solid_objects/component_token.rb b/lib/solid_objects/component_token.rb new file mode 100644 index 0000000..9e635a4 --- /dev/null +++ b/lib/solid_objects/component_token.rb @@ -0,0 +1,139 @@ +# rbs_inline: enabled + +require "active_support/message_verifier" + +module SolidObjects + module ComponentToken + PURPOSE = "solid_objects.actor_component" + MAXIMUM_TOKEN_BYTES = 16_384 + MAXIMUM_DEPENDENCIES = 50 + + module_function + + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String + def generate( + reference:, + component_name:, + dependencies:, + instance_id:, + revision:, + refresh_path: + ) + payload = { + "actor_type" => reference.actor_type, + "actor_id" => reference.actor_id, + "component_name" => component_name, + "dependencies" => dependencies, + "instance_id" => instance_id, + "revision" => revision, + "refresh_path" => refresh_path + } + validate_payload!(payload) + verifier.generate(payload, purpose: PURPOSE).tap do |token| + raise InvalidComponentToken, "component token is too large" if token.bytesize > MAXIMUM_TOKEN_BYTES + end + end + + # @rbs (String) -> Hash[String, untyped] + def verify(token) + unless token.is_a?(String) && token.bytesize <= MAXIMUM_TOKEN_BYTES + raise InvalidComponentToken, "invalid actor component token" + end + + payload = verifier.verified(token, purpose: PURPOSE) + validate_payload!(payload) + payload + rescue ActiveSupport::MessageVerifier::InvalidSignature + raise InvalidComponentToken, "invalid actor component token" + end + + # @rbs (Hash[String, untyped]) -> Hash[String, untyped] + def validate_payload!(payload) + unless payload.is_a?(Hash) && + payload["actor_type"].is_a?(String) && + payload["actor_id"].is_a?(String) && + payload["component_name"].is_a?(String) && + payload["dependencies"].is_a?(Array) && + payload["instance_id"].is_a?(Integer) && + payload["revision"].is_a?(Integer) && + payload["refresh_path"].is_a?(String) + raise InvalidComponentToken, "invalid actor component token" + end + + validate_component_name!(payload.fetch("component_name")) + validate_dependencies!(payload.fetch("dependencies")) + validate_revision!( + payload.fetch("instance_id"), + payload.fetch("revision") + ) + validate_refresh_path!(payload.fetch("refresh_path")) + payload + end + private_class_method :validate_payload! + + # @rbs (String) -> void + def validate_component_name!(component_name) + return if component_name.match?(/\A[a-zA-Z0-9_]+\z/) + + raise InvalidComponentToken, "invalid actor component name" + end + private_class_method :validate_component_name! + + # @rbs (Array[untyped]) -> void + def validate_dependencies!(dependencies) + valid = dependencies.any? && + dependencies.length <= MAXIMUM_DEPENDENCIES && + dependencies.uniq.length == dependencies.length && + dependencies.all? { |dependency| dependency.is_a?(String) && dependency.match?(/\A[a-zA-Z0-9_]+\z/) } + return if valid + + raise InvalidComponentToken, "invalid actor component dependencies" + end + private_class_method :validate_dependencies! + + # @rbs (Integer, Integer) -> void + def validate_revision!(instance_id, revision) + return unless instance_id.negative? || revision.negative? + + raise InvalidComponentToken, "invalid actor component revision" + end + private_class_method :validate_revision! + + # @rbs (String) -> void + def validate_refresh_path!(refresh_path) + valid = refresh_path.bytesize <= 2_048 && + refresh_path.start_with?("/") && + !refresh_path.start_with?("//") && + !refresh_path.include?("\0") && + !refresh_path.include?("?") && + !refresh_path.include?("#") + return if valid + + raise InvalidComponentToken, "invalid actor component refresh path" + end + private_class_method :validate_refresh_path! + + # @rbs () -> ActiveSupport::MessageVerifier + def verifier + ActiveSupport::MessageVerifier.new( + signing_secret, + digest: "SHA256", + serializer: JSON + ) + end + private_class_method :verifier + + # @rbs () -> String + def signing_secret + configured_secret = SolidObjects.configuration.stream_signing_secret + return configured_secret if configured_secret + + if defined?(Rails) && Rails.respond_to?(:application) && Rails.application + return Rails.application.key_generator.generate_key(PURPOSE, 64) + end + + raise ArgumentError, "configure stream_signing_secret outside a Rails application" + end + private_class_method :signing_secret + end +end diff --git a/lib/solid_objects/component_view.rb b/lib/solid_objects/component_view.rb new file mode 100644 index 0000000..33b8480 --- /dev/null +++ b/lib/solid_objects/component_view.rb @@ -0,0 +1,67 @@ +# rbs_inline: enabled + +module SolidObjects + class ComponentView + # @rbs @snapshot: ActorSnapshot + # @rbs @dependencies: Array[String] + # @rbs @authorization_context: untyped + + attr_reader :authorization_context + + # @rbs (snapshot: ActorSnapshot, dependencies: Array[String], authorization_context: untyped) -> void + def initialize(snapshot:, dependencies:, authorization_context:) + @snapshot = snapshot + @dependencies = dependencies + @authorization_context = authorization_context + end + + # @rbs () -> Reference + def reference + snapshot.reference + end + + # @rbs () -> String + def actor_id + reference.actor_id + end + + # @rbs () -> void + def state + raise UnknownComponentDependency, + "reactive components must read declared observables instead of actor.state" + end + + # @rbs (Symbol, *untyped, **untyped) -> untyped + def method_missing(name, *arguments, **keywords) + return super unless arguments.empty? && keywords.empty? + return observable_value(name) if observable?(name) + + super + end + + # @rbs (Symbol, bool) -> bool + def respond_to_missing?(name, include_private = false) + observable?(name) || super + end + + private + + attr_reader :snapshot, :dependencies + + # @rbs (Symbol | String) -> bool + def observable?(name) + snapshot.actor_class.definition.observables.key?(name.to_sym) + end + + # @rbs (Symbol | String) -> untyped + def observable_value(name) + dependency = name.to_s + unless dependencies.include?(dependency) + raise UnknownComponentDependency, + "observable #{dependency.inspect} is not declared by this component" + end + + snapshot.observable_value(dependency) + end + end +end diff --git a/lib/solid_objects/configuration.rb b/lib/solid_objects/configuration.rb index 590d61a..ee7f09c 100644 --- a/lib/solid_objects/configuration.rb +++ b/lib/solid_objects/configuration.rb @@ -34,6 +34,8 @@ class Configuration # @rbs @stream_signing_secret: String? # @rbs @broadcast_adapter: Proc? # @rbs @wake_up_adapter: untyped + # @rbs @component_path_resolver: Proc? + # @rbs @component_authorization_context: Proc # @rbs @authorize_message: Proc # @rbs @authorize_query: Proc # @rbs @authorize_destroy: Proc @@ -72,6 +74,8 @@ class Configuration :stream_signing_secret, :broadcast_adapter, :wake_up_adapter, + :component_path_resolver, + :component_authorization_context, :authorize_message, :authorize_query, :authorize_destroy, @@ -111,6 +115,8 @@ def initialize @stream_signing_secret = nil @broadcast_adapter = nil @wake_up_adapter = nil + @component_path_resolver = nil + @component_authorization_context = ->(controller:) { controller } @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger Rails.logger else @@ -151,6 +157,12 @@ def validate! raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty? raise ArgumentError, "instance retention must be positive" unless retention.positive? end + unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call) + raise ArgumentError, "component_path_resolver must respond to call" + end + unless component_authorization_context.respond_to?(:call) + raise ArgumentError, "component_authorization_context must respond to call" + end self end diff --git a/lib/solid_objects/errors.rb b/lib/solid_objects/errors.rb index bc08a33..73b40d9 100644 --- a/lib/solid_objects/errors.rb +++ b/lib/solid_objects/errors.rb @@ -37,6 +37,18 @@ class Unauthorized < Error class InvalidStreamToken < Error end + class InvalidComponentToken < Error + end + + class UnknownComponent < Error + end + + class UnknownComponentDependency < Error + end + + class UnsupportedComponentRendering < Error + end + class LostActivation < Error end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index 2762e25..ce3dbb9 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -98,6 +98,7 @@ def complete(result, observable_changes) instance.update!( state: serialized_state, state_version: actor.class.state_version, + state_revision: locked_message.sequence, last_used_at: SolidObjects.database_adapter.database_now ) locked_message.update!( diff --git a/lib/solid_objects/stream_token.rb b/lib/solid_objects/stream_token.rb index a1b200e..f86ae00 100644 --- a/lib/solid_objects/stream_token.rb +++ b/lib/solid_objects/stream_token.rb @@ -5,33 +5,52 @@ module SolidObjects module StreamToken PURPOSE = "solid_objects.actor_stream" + MAXIMUM_OBSERVABLES = 100 module_function - # @rbs (Reference) -> String - def generate(reference) - verifier.generate( - { - "actor_type" => reference.actor_type, - "actor_id" => reference.actor_id - }, - purpose: PURPOSE - ) + # @rbs (Reference, ?observables: Array[String]?) -> String + def generate(reference, observables: nil) + identity = { + "actor_type" => reference.actor_type, + "actor_id" => reference.actor_id + } + identity["observables"] = observables if observables + validate_identity!(identity) + verifier.generate(identity, purpose: PURPOSE) end - # @rbs (String) -> Hash[String, String] + # @rbs (String) -> Hash[String, untyped] def verify(token) identity = verifier.verified(token, purpose: PURPOSE) + validate_identity!(identity) + rescue ActiveSupport::MessageVerifier::InvalidSignature + raise InvalidStreamToken, "invalid actor stream token" + end + + # @rbs (Hash[String, untyped]) -> Hash[String, untyped] + def validate_identity!(identity) unless identity.is_a?(Hash) && identity["actor_type"].is_a?(String) && identity["actor_id"].is_a?(String) raise InvalidStreamToken, "invalid actor stream token" end - identity - rescue ActiveSupport::MessageVerifier::InvalidSignature - raise InvalidStreamToken, "invalid actor stream token" + observables = identity["observables"] + return identity unless observables + + valid = observables.is_a?(Array) && + observables.length <= MAXIMUM_OBSERVABLES && + observables.uniq.length == observables.length && + observables.all? do |observable| + observable.is_a?(String) && + observable.match?(/\A[a-zA-Z0-9_]+\z/) + end + return identity if valid + + raise InvalidStreamToken, "invalid actor stream observables" end + private_class_method :validate_identity! # @rbs () -> ActiveSupport::MessageVerifier def verifier diff --git a/lib/solid_objects/turbo_stream_renderer.rb b/lib/solid_objects/turbo_stream_renderer.rb index 7b7bf62..54b4b77 100644 --- a/lib/solid_objects/turbo_stream_renderer.rb +++ b/lib/solid_objects/turbo_stream_renderer.rb @@ -1,9 +1,13 @@ # rbs_inline: enabled require "erb" +require "base64" module SolidObjects module TurboStreamRenderer + INVALIDATION_MARKER = "solid_objects_invalidation:" + INVALIDATION_PATTERN = // + module_function # @rbs (Broadcast) -> String @@ -12,7 +16,19 @@ def observable(broadcast) actor_type: broadcast.instance.actor_type, actor_id: broadcast.instance.actor_id ) - observable_value(reference, broadcast.observable_name, broadcast.value) + stream = observable_value( + reference, + broadcast.observable_name, + broadcast.value + ) + metadata = Base64.urlsafe_encode64( + JSON.generate( + "instance_id" => broadcast.instance_id, + "revision" => broadcast.message.sequence, + "observable_name" => broadcast.observable_name + ) + ) + "#{stream}" end # @rbs (Reference, Symbol | String, untyped) -> String @@ -22,6 +38,34 @@ def observable_value(reference, name, value) %() end + # @rbs (ComponentRegistration, Integer, Integer) -> String + def component_refresh(registration, instance_id, revision) + target = DomIdentity.component( + registration.reference, + registration.component_name + ) + source = ERB::Util.html_escape( + registration.refresh_url(instance_id, revision) + ) + revision_value = "#{instance_id}:#{revision}" + %() + end + + # @rbs (String) -> Hash[String, untyped]? + def invalidation(stream) + encoded = stream[INVALIDATION_PATTERN, 1] + return unless encoded + + payload = JSON.parse(Base64.urlsafe_decode64(encoded)) + return unless payload["instance_id"].is_a?(Integer) + return unless payload["revision"].is_a?(Integer) + return unless payload["observable_name"].is_a?(String) + + payload + rescue ArgumentError, JSON::ParserError + nil + end + # @rbs (untyped) -> String def display_value(value) return value if value.is_a?(String) diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 7694e49..4a4b5ed 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.3.0" + VERSION = "0.4.0" end diff --git a/sig/generated/controllers/solid_objects/components_controller.rbs b/sig/generated/controllers/solid_objects/components_controller.rbs new file mode 100644 index 0000000..87b582e --- /dev/null +++ b/sig/generated/controllers/solid_objects/components_controller.rbs @@ -0,0 +1,22 @@ +# Generated from app/controllers/solid_objects/components_controller.rb with RBS::Inline + +module SolidObjects + class ComponentsController < ActionController::Base + # @rbs () -> void + def show: () -> void + + private + + # @rbs () -> Array[Integer] + def requested_revision_key: () -> Array[Integer] + + # @rbs (Array[Integer], ActorSnapshot) -> bool + def newer_than_snapshot?: (Array[Integer], ActorSnapshot) -> bool + + # @rbs () -> untyped + def component_view_context: () -> untyped + + # @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String + def component_frame: (ComponentRegistration, ActorSnapshot, untyped) -> String + end +end diff --git a/sig/generated/lib/solid_objects/actor.rbs b/sig/generated/lib/solid_objects/actor.rbs index 17fd2fb..d923809 100644 --- a/sig/generated/lib/solid_objects/actor.rbs +++ b/sig/generated/lib/solid_objects/actor.rbs @@ -166,6 +166,9 @@ module SolidObjects # @rbs () -> Hash[String, untyped] def observable_values: () -> Hash[String, untyped] + # @rbs (Symbol | String) -> untyped + def observable_value: (Symbol | String) -> untyped + # @rbs () -> void def activate: () -> void diff --git a/sig/generated/lib/solid_objects/actor_channel.rbs b/sig/generated/lib/solid_objects/actor_channel.rbs index 518530d..ece5bc6 100644 --- a/sig/generated/lib/solid_objects/actor_channel.rbs +++ b/sig/generated/lib/solid_objects/actor_channel.rbs @@ -4,5 +4,25 @@ module SolidObjects class ActorChannel < ActionCable::Channel::Base # @rbs () -> void def subscribed: () -> void + + private + + attr_reader reference: untyped + + attr_reader component_subscriptions: untyped + + attr_reader scalar_observables: untyped + + # @rbs (String) -> void + def receive_broadcast: (String) -> void + + # @rbs (ActorSnapshot) -> void + def refresh_outdated_components: (ActorSnapshot) -> void + + # @rbs () -> void + def validate_scalar_observables!: () -> void + + # @rbs (ActorSnapshot) -> Array[String] + def scalar_observable_names: (ActorSnapshot) -> Array[String] end end diff --git a/sig/generated/lib/solid_objects/actor_snapshot.rbs b/sig/generated/lib/solid_objects/actor_snapshot.rbs index 3744320..a674f30 100644 --- a/sig/generated/lib/solid_objects/actor_snapshot.rbs +++ b/sig/generated/lib/solid_objects/actor_snapshot.rbs @@ -8,20 +8,37 @@ module SolidObjects @actor: Actor + @instance_id: Integer + + @revision: Integer + + @observable_values: Hash[String, untyped]? + + @observable_value_cache: Hash[String, untyped] + attr_reader reference: untyped attr_reader actor_class: untyped attr_reader actor: untyped + attr_reader instance_id: untyped + + attr_reader revision: untyped + # @rbs (Reference) -> void def initialize: (Reference) -> void # @rbs () -> Hash[String, untyped] def observable_values: () -> Hash[String, untyped] + # @rbs (Symbol | String) -> untyped + def observable_value: (Symbol | String) -> untyped + private + attr_reader instance: untyped + # @rbs () -> Actor def build_actor: () -> Actor end diff --git a/sig/generated/lib/solid_objects/actor_view.rbs b/sig/generated/lib/solid_objects/actor_view.rbs index 3a98362..254d052 100644 --- a/sig/generated/lib/solid_objects/actor_view.rbs +++ b/sig/generated/lib/solid_objects/actor_view.rbs @@ -2,6 +2,10 @@ module SolidObjects class ActorView + @observable_names: Array[String] + + @component_registrations: Array[ComponentRegistration] + @snapshot: ActorSnapshot @authorization_context: untyped @@ -18,8 +22,14 @@ module SolidObjects # @rbs (Symbol | String) -> untyped def value: (Symbol | String) -> untyped - # @rbs (Symbol | String, ?partial: String?) -> untyped - def component: (Symbol | String, ?partial: String?) -> untyped + # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped + def component: (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped + + # @rbs () -> Array[String] + def component_tokens: () -> Array[String] + + # @rbs () -> Array[String] + def scalar_observable_names: () -> Array[String] # @rbs () -> State def state: () -> State @@ -38,6 +48,13 @@ module SolidObjects attr_reader snapshot: untyped + attr_reader component_registrations: untyped + + attr_reader observable_names: untyped + + # @rbs (String, partial: String?) -> untyped + def static_component: (String, partial: String?) -> untyped + # @rbs (Symbol | String) -> void def authorize_read!: (Symbol | String) -> void @@ -50,6 +67,18 @@ module SolidObjects # @rbs (Symbol | String) -> String def normalized_component_name: (Symbol | String) -> String + # @rbs (Symbol | String | Array[Symbol | String]) -> Array[String] + def normalized_dependencies: (Symbol | String | Array[Symbol | String]) -> Array[String] + + # @rbs (Array[String]) -> void + def validate_dependencies!: (Array[String]) -> void + + # @rbs (String) -> void + def ensure_unique_component!: (String) -> void + + # @rbs () -> Proc | ComponentPathResolver + def component_path_resolver: () -> Proc + # @rbs (String) -> String def default_partial: (String) -> String end diff --git a/sig/generated/lib/solid_objects/component_path_resolver.rbs b/sig/generated/lib/solid_objects/component_path_resolver.rbs new file mode 100644 index 0000000..1a43e5d --- /dev/null +++ b/sig/generated/lib/solid_objects/component_path_resolver.rbs @@ -0,0 +1,13 @@ +# Generated from lib/solid_objects/component_path_resolver.rb with RBS::Inline + +module SolidObjects + class ComponentPathResolver + # @rbs (view_context: untyped) -> String + def call: (view_context: untyped) -> String + + private + + # @rbs () -> bool + def mounted?: () -> bool + end +end diff --git a/sig/generated/lib/solid_objects/component_registration.rbs b/sig/generated/lib/solid_objects/component_registration.rbs new file mode 100644 index 0000000..86b580b --- /dev/null +++ b/sig/generated/lib/solid_objects/component_registration.rbs @@ -0,0 +1,51 @@ +# Generated from lib/solid_objects/component_registration.rb with RBS::Inline + +module SolidObjects + class ComponentRegistration + @reference: Reference + + @component_name: String + + @dependencies: Array[String] + + @instance_id: Integer + + @revision: Integer + + @refresh_path: String + + @token: String + + attr_reader reference: untyped + + attr_reader component_name: untyped + + attr_reader dependencies: untyped + + attr_reader instance_id: untyped + + attr_reader revision: untyped + + attr_reader refresh_path: untyped + + attr_reader token: untyped + + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void + def initialize: (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void + + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration + def self.issue: (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration + + # @rbs (String) -> ComponentRegistration + def self.from_token: (String) -> ComponentRegistration + + # @rbs (Reference, Array[String]) -> void + private def self.validate_dependencies!: (Reference, Array[String]) -> void + + # @rbs () -> Array[Integer] + def revision_key: () -> Array[Integer] + + # @rbs (Integer, Integer) -> String + def refresh_url: (Integer, Integer) -> String + end +end diff --git a/sig/generated/lib/solid_objects/component_renderer.rbs b/sig/generated/lib/solid_objects/component_renderer.rbs new file mode 100644 index 0000000..c0db81e --- /dev/null +++ b/sig/generated/lib/solid_objects/component_renderer.rbs @@ -0,0 +1,39 @@ +# Generated from lib/solid_objects/component_renderer.rb with RBS::Inline + +module SolidObjects + class ComponentRenderer + @snapshot: ActorSnapshot + + @component_name: String + + @dependencies: Array[String] + + @view_context: untyped + + @authorization_context: untyped + + # @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void + def initialize: (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void + + # @rbs () -> untyped + def call: () -> untyped + + private + + attr_reader snapshot: untyped + + attr_reader component_name: untyped + + attr_reader dependencies: untyped + + attr_reader view_context: untyped + + attr_reader authorization_context: untyped + + # @rbs (String) -> void + def authorize_read!: (String) -> void + + # @rbs () -> String + def default_partial: () -> String + end +end diff --git a/sig/generated/lib/solid_objects/component_subscriptions.rbs b/sig/generated/lib/solid_objects/component_subscriptions.rbs new file mode 100644 index 0000000..3cd503a --- /dev/null +++ b/sig/generated/lib/solid_objects/component_subscriptions.rbs @@ -0,0 +1,40 @@ +# Generated from lib/solid_objects/component_subscriptions.rb with RBS::Inline + +module SolidObjects + class ComponentSubscriptions + MAXIMUM_COMPONENTS: ::Integer + + MAXIMUM_SERIALIZED_BYTES: ::Integer + + @registrations: Array[ComponentRegistration] + + @revisions: Hash[String, Array[Integer]] + + # @rbs (String?, reference: Reference) -> ComponentSubscriptions + def self.parse: (String?, reference: Reference) -> ComponentSubscriptions + + # @rbs (Array[ComponentRegistration]) -> void + def initialize: (Array[ComponentRegistration]) -> void + + # @rbs (Hash[String, untyped]) -> Array[String] + def refreshes_for: (Hash[String, untyped]) -> Array[String] + + # @rbs (ActorSnapshot) -> Array[String] + def reconnect_refreshes: (ActorSnapshot) -> Array[String] + + # @rbs (ComponentRegistration, Reference) -> void + private def self.validate_identity!: (ComponentRegistration, Reference) -> void + + private + + attr_reader registrations: untyped + + attr_reader revisions: untyped + + # @rbs (ComponentRegistration, Integer, Integer) -> String + def refresh: (ComponentRegistration, Integer, Integer) -> String + + # @rbs (String, Integer, Integer) -> bool + def newer_revision?: (String, Integer, Integer) -> bool + end +end diff --git a/sig/generated/lib/solid_objects/component_token.rbs b/sig/generated/lib/solid_objects/component_token.rbs new file mode 100644 index 0000000..1a2cd3b --- /dev/null +++ b/sig/generated/lib/solid_objects/component_token.rbs @@ -0,0 +1,38 @@ +# Generated from lib/solid_objects/component_token.rb with RBS::Inline + +module SolidObjects + module ComponentToken + PURPOSE: ::String + + MAXIMUM_TOKEN_BYTES: ::Integer + + MAXIMUM_DEPENDENCIES: ::Integer + + # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String + def self?.generate: (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String + + # @rbs (String) -> Hash[String, untyped] + def self?.verify: (String) -> Hash[String, untyped] + + # @rbs (Hash[String, untyped]) -> Hash[String, untyped] + def self?.validate_payload!: (Hash[String, untyped]) -> Hash[String, untyped] + + # @rbs (String) -> void + def self?.validate_component_name!: (String) -> void + + # @rbs (Array[untyped]) -> void + def self?.validate_dependencies!: (Array[untyped]) -> void + + # @rbs (Integer, Integer) -> void + def self?.validate_revision!: (Integer, Integer) -> void + + # @rbs (String) -> void + def self?.validate_refresh_path!: (String) -> void + + # @rbs () -> ActiveSupport::MessageVerifier + def self?.verifier: () -> ActiveSupport::MessageVerifier + + # @rbs () -> String + def self?.signing_secret: () -> String + end +end diff --git a/sig/generated/lib/solid_objects/component_view.rbs b/sig/generated/lib/solid_objects/component_view.rbs new file mode 100644 index 0000000..35d0a41 --- /dev/null +++ b/sig/generated/lib/solid_objects/component_view.rbs @@ -0,0 +1,43 @@ +# Generated from lib/solid_objects/component_view.rb with RBS::Inline + +module SolidObjects + class ComponentView + @snapshot: ActorSnapshot + + @dependencies: Array[String] + + @authorization_context: untyped + + attr_reader authorization_context: untyped + + # @rbs (snapshot: ActorSnapshot, dependencies: Array[String], authorization_context: untyped) -> void + def initialize: (snapshot: ActorSnapshot, dependencies: Array[String], authorization_context: untyped) -> void + + # @rbs () -> Reference + def reference: () -> Reference + + # @rbs () -> String + def actor_id: () -> String + + # @rbs () -> void + def state: () -> void + + # @rbs (Symbol, *untyped, **untyped) -> untyped + def method_missing: (Symbol, *untyped, **untyped) -> untyped + + # @rbs (Symbol, bool) -> bool + def respond_to_missing?: (Symbol, bool) -> bool + + private + + attr_reader snapshot: untyped + + attr_reader dependencies: untyped + + # @rbs (Symbol | String) -> bool + def observable?: (Symbol | String) -> bool + + # @rbs (Symbol | String) -> untyped + def observable_value: (Symbol | String) -> untyped + end +end diff --git a/sig/generated/lib/solid_objects/configuration.rbs b/sig/generated/lib/solid_objects/configuration.rbs index 92cf3b0..75493b0 100644 --- a/sig/generated/lib/solid_objects/configuration.rbs +++ b/sig/generated/lib/solid_objects/configuration.rbs @@ -4,8 +4,6 @@ module SolidObjects class Configuration @table_name_prefix: String - @process_alive_threshold: Float - @shutdown_timeout: Float @message_retention: Numeric @@ -36,6 +34,10 @@ module SolidObjects @wake_up_adapter: untyped + @component_path_resolver: Proc? + + @component_authorization_context: Proc + @authorize_message: Proc @authorize_query: Proc @@ -76,6 +78,8 @@ module SolidObjects @process_heartbeat_interval: Float + @process_alive_threshold: Float + attr_accessor table_name_prefix: untyped attr_accessor polling_interval: untyped @@ -140,6 +144,10 @@ module SolidObjects attr_accessor wake_up_adapter: untyped + attr_accessor component_path_resolver: untyped + + attr_accessor component_authorization_context: untyped + attr_accessor authorize_message: untyped attr_accessor authorize_query: untyped diff --git a/sig/generated/lib/solid_objects/errors.rbs b/sig/generated/lib/solid_objects/errors.rbs index 4096405..1c54ade 100644 --- a/sig/generated/lib/solid_objects/errors.rbs +++ b/sig/generated/lib/solid_objects/errors.rbs @@ -37,6 +37,18 @@ module SolidObjects class InvalidStreamToken < Error end + class InvalidComponentToken < Error + end + + class UnknownComponent < Error + end + + class UnknownComponentDependency < Error + end + + class UnsupportedComponentRendering < Error + end + class LostActivation < Error end diff --git a/sig/generated/lib/solid_objects/stream_token.rbs b/sig/generated/lib/solid_objects/stream_token.rbs index 8cca1f8..dbb2069 100644 --- a/sig/generated/lib/solid_objects/stream_token.rbs +++ b/sig/generated/lib/solid_objects/stream_token.rbs @@ -4,11 +4,16 @@ module SolidObjects module StreamToken PURPOSE: ::String - # @rbs (Reference) -> String - def self?.generate: (Reference) -> String + MAXIMUM_OBSERVABLES: ::Integer - # @rbs (String) -> Hash[String, String] - def self?.verify: (String) -> Hash[String, String] + # @rbs (Reference, ?observables: Array[String]?) -> String + def self?.generate: (Reference, ?observables: Array[String]?) -> String + + # @rbs (String) -> Hash[String, untyped] + def self?.verify: (String) -> Hash[String, untyped] + + # @rbs (Hash[String, untyped]) -> Hash[String, untyped] + def self?.validate_identity!: (Hash[String, untyped]) -> Hash[String, untyped] # @rbs () -> ActiveSupport::MessageVerifier def self?.verifier: () -> ActiveSupport::MessageVerifier diff --git a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs index 15fecff..4ba3c0c 100644 --- a/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +++ b/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs @@ -2,12 +2,22 @@ module SolidObjects module TurboStreamRenderer + INVALIDATION_MARKER: ::String + + INVALIDATION_PATTERN: ::Regexp + # @rbs (Broadcast) -> String def self?.observable: (Broadcast) -> String # @rbs (Reference, Symbol | String, untyped) -> String def self?.observable_value: (Reference, Symbol | String, untyped) -> String + # @rbs (ComponentRegistration, Integer, Integer) -> String + def self?.component_refresh: (ComponentRegistration, Integer, Integer) -> String + + # @rbs (String) -> Hash[String, untyped]? + def self?.invalidation: (String) -> Hash[String, untyped]? + # @rbs (untyped) -> String def self?.display_value: (untyped) -> String end diff --git a/test/database_test_helper.rb b/test/database_test_helper.rb index 2447bd0..fddca8e 100644 --- a/test/database_test_helper.rb +++ b/test/database_test_helper.rb @@ -22,8 +22,10 @@ ActiveRecord::Migration.verbose = false require_relative "../db/migrate/20260805000000_create_solid_objects_tables" +require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" CreateSolidObjectsTables.new.migrate(:up) +AddStateRevisionToSolidObjectsInstances.new.migrate(:up) ActiveRecord::Base.connection.create_table(:solid_objects_test_domain_records) do |table| table.string :name, null: false diff --git a/test/dummy/component_path_check.rb b/test/dummy/component_path_check.rb new file mode 100644 index 0000000..c3eb009 --- /dev/null +++ b/test/dummy/component_path_check.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +require_relative "config/environment" + +Rails.application.reload_routes! +puts SolidObjects::ComponentPathResolver.new.call(view_context: Object.new) diff --git a/test/integration/actor_channel_test.rb b/test/integration/actor_channel_test.rb index 74c10f8..ae159b6 100644 --- a/test/integration/actor_channel_test.rb +++ b/test/integration/actor_channel_test.rb @@ -14,14 +14,31 @@ class ChannelActor < SolidObjects::Actor actor_type "channel-actor" attribute :missing, default: 0 + attribute :status, default: "open" + attribute :unrelated, default: 0 observable :missing + observable :status + observable :unrelated + + def update_all + self.missing += 1 + self.status = "closed" + self.unrelated += 1 + end + + def update_missing + self.missing += 1 + end end setup do SolidObjects.reset! ChannelActor.ensure_registered! SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" + SolidObjects.configuration.authorize_message = ->(**) { true } + SolidObjects.configuration.authorize_query = ->(**) { true } + ActionCable.server.config.logger = Logger.new(nil) end test "streams only after token verification and host authorization" do @@ -37,8 +54,10 @@ class ChannelActor < SolidObjects::Actor assert subscription.confirmed? assert_has_stream SolidObjects::StreamName.for(reference) - assert_equal 1, transmissions.length - assert_includes transmissions.first, SolidObjects::DomIdentity.observable(reference, :missing) + assert_equal 3, transmissions.length + assert(transmissions.any? do |transmission| + transmission.include?(SolidObjects::DomIdentity.observable(reference, :missing)) + end) end test "rejects a valid token when host authorization fails" do @@ -60,4 +79,199 @@ class ChannelActor < SolidObjects::Actor assert subscription.rejected? assert_no_streams end + + test "refreshes a component once when several dependencies change in one turn" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + subscribe( + token: SolidObjects::StreamToken.generate(reference), + components: JSON.generate( + [ + component_token( + reference, + component_name: "summary", + dependencies: %w[missing status], + revision: 0 + ) + ] + ) + ) + ChannelActor.ref("actor-1").async(:update_all) + worker = SolidObjects::Worker.new + worker.run_until_idle + + SolidObjects::Broadcast.where(observable_name: %w[missing status]).find_each do |broadcast| + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + end + + assert_equal 1, component_refreshes(reference, :summary).length + ensure + worker&.stop + end + + test "does not refresh a component for an unrelated observable" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + subscribe( + token: SolidObjects::StreamToken.generate(reference), + components: JSON.generate( + [ + component_token( + reference, + component_name: "summary", + dependencies: %w[status], + revision: 0 + ) + ] + ) + ) + ChannelActor.ref("actor-1").async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + assert_empty component_refreshes(reference, :summary) + ensure + worker&.stop + end + + test "does not transmit component dependency values as scalar updates" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + subscribe( + token: SolidObjects::StreamToken.generate(reference, observables: []), + components: JSON.generate( + [ + component_token( + reference, + component_name: "summary", + dependencies: %w[missing], + revision: 0 + ) + ] + ) + ) + reference.async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + scalar_target = SolidObjects::DomIdentity.observable(reference, :missing) + refute transmissions.any? { |transmission| transmission.include?(scalar_target) } + assert_equal 1, component_refreshes(reference, :summary).length + ensure + worker&.stop + end + + test "refreshes stale components from current state after reconnect" do + reference = ChannelActor.ref("actor-1") + reference.update_missing + SolidObjects.configuration.authorize_subscription = ->(**) { true } + + subscribe( + token: SolidObjects::StreamToken.generate(reference), + components: JSON.generate( + [ + component_token( + reference, + component_name: "summary", + dependencies: %w[missing], + revision: 0 + ) + ] + ) + ) + + refreshes = component_refreshes(reference, :summary) + assert_equal 1, refreshes.length, transmissions.inspect + refresh = refreshes.first + assert_includes refresh, "revision=1" + refute_includes refresh, "alice" + refute_includes refresh, "bob" + end + + test "ignores an older component invalidation after a newer one" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + subscribe( + token: SolidObjects::StreamToken.generate(reference), + components: JSON.generate( + [ + component_token( + reference, + component_name: "summary", + dependencies: %w[missing], + revision: 0 + ) + ] + ) + ) + 2.times { reference.async(:update_missing) } + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcasts = SolidObjects::Broadcast + .where(observable_name: "missing") + .includes(:message) + .sort_by { |broadcast| broadcast.message.sequence } + + broadcasts.reverse_each do |broadcast| + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + end + + refreshes = component_refreshes(reference, :summary) + assert_equal 1, refreshes.length + assert_includes refreshes.first, "revision=2" + ensure + worker&.stop + end + + test "rejects malformed component registrations" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + + subscribe( + token: SolidObjects::StreamToken.generate(reference), + components: JSON.generate([ "malformed" ]) + ) + + assert subscription.rejected? + assert_no_streams + end + + private + + def component_token(reference, component_name:, dependencies:, revision:) + SolidObjects::ComponentToken.generate( + reference:, + component_name:, + dependencies:, + instance_id: 0, + revision:, + refresh_path: "/solid_objects/components" + ) + end + + def component_refreshes(reference, component_name) + target = SolidObjects::DomIdentity.component(reference, component_name) + transmissions.select do |transmission| + transmission.include?(%(target="#{target}")) && + transmission.include?(" { [] } + attribute :status, default: "open" observable :items_count do items.length end + + observable :items + observable :status + + def replace_items(items:) + self.items = items + end + + def close + self.status = "closed" + end end setup do SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" + SolidObjects.configuration.component_path_resolver = lambda do |view_context:| + "/solid_objects/components" + end CartActor.ensure_registered! + @controller.prepend_view_path( + ActionView::FixtureResolver.new( + "actors/actor_helper_test/cart_actor/_items.html.erb" => <<~ERB, +
        + <% actor.items.each do |item| %> +
      • <%= item.fetch("name") %>
      • + <% end %> +
      + ERB + "actors/actor_helper_test/cart_actor/_status.html.erb" => <<~ERB, + <% if actor.status == "closed" %> +

      Closed

      + <% else %> +

      Open

      + <% end %> + ERB + "actors/actor_helper_test/cart_actor/_summary.html.erb" => <<~ERB, +

      <%= actor.items.length %> items

      + ERB + "actors/actor_helper_test/cart_actor/_leaky.html.erb" => <<~ERB +

      <%= actor.status %>

      + ERB + ) + ) end test "renders initial observable values with one actor subscription" do @@ -34,6 +74,10 @@ class CartActor < SolidObjects::Actor assert_includes html, %(channel="SolidObjects::ActorChannel") assert_includes html, %(id="#{SolidObjects::DomIdentity.scope(reference)}") refute_includes html, reference.actor_id + + token = html[/token="([^"]+)"/, 1] + identity = SolidObjects::StreamToken.verify(token) + assert_equal [ "items_count" ], identity.fetch("observables") end test "authorizes initial actor state reads" do @@ -47,4 +91,122 @@ class CartActor < SolidObjects::Actor test "does not expose the old actor scope helper" do assert_not respond_to?(:actor_scope, true) end + + test "renders collection components as ordinary ERB values" do + reference = CartActor.ref("alice") + reference.replace_items( + items: [ + { name: "" }, + { name: "Second" } + ] + ) + + html = solid_object(reference, authorization_context: "alice") do |actor| + actor.component(:items, observes: :items) + end + + assert_includes html, "<First>
    2. " + assert_includes html, "
    3. Second
    4. " + refute_includes html, JSON.generate(reference.snapshot.items) + assert_includes html, "data-components=" + + token = html[/token="([^"]+)"/, 1] + identity = SolidObjects::StreamToken.verify(token) + assert_empty identity.fetch("observables") + end + + test "renders conditional components from declared dependencies" do + reference = CartActor.ref("alice") + + open_html = solid_object(reference) do |actor| + actor.component(:status, observes: :status) + end + reference.close + closed_html = solid_object(reference) do |actor| + actor.component(:status, observes: :status) + end + + assert_includes open_html, "

      Open

      " + assert_includes closed_html, "

      Closed

      " + end + + test "rejects unknown component dependencies" do + error = assert_raises(SolidObjects::UnknownComponentDependency) do + solid_object(CartActor.ref("alice")) do |actor| + actor.component(:items, observes: :private_items) + end + end + + assert_match(/private_items/, error.message) + end + + test "rejects arbitrary partial paths for reactive components" do + assert_raises(ArgumentError) do + solid_object(CartActor.ref("alice")) do |actor| + actor.component( + :items, + observes: :items, + partial: "../../secrets" + ) + end + end + end + + test "authorizes initial component rendering with the view context" do + authorization_calls = [] + SolidObjects.configuration.authorize_query = lambda do |**arguments| + authorization_calls << arguments + arguments.fetch(:authorization_context) == "alice" + end + + html = solid_object( + CartActor.ref("alice"), + authorization_context: "alice" + ) do |actor| + actor.component(:items, observes: :items) + end + + assert_includes html, " { [] } + attribute :status, default: "open" + + observable :recent_messages + observable :status + + def replace_messages(messages:) + self.recent_messages = messages + end + + def update_room(messages:, status:) + self.recent_messages = messages + self.status = status + end + end + + setup do + @routes = ActionDispatch::Routing::RouteSet.new + @routes.draw do + get "components", to: "solid_objects/components#show" + end + @controller.prepend_view_path( + ActionView::FixtureResolver.new( + "actors/components_controller_test/room_actor/_messages.html.erb" => <<~ERB, +

      <%= authorization_context %>

      +
        + <% actor.recent_messages.each do |message| %> +
      • "><%= message.fetch("body") %>
      • + <% end %> +
      + ERB + "actors/components_controller_test/room_actor/_presence.html.erb" => <<~ERB + <% if actor.status == "closed" %> +

      Room closed

      + <% else %> +

      <%= actor.recent_messages.length %> present

      + <% end %> + ERB + ) + ) + SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" + SolidObjects.configuration.component_authorization_context = lambda do |controller:| + controller.request.headers["HTTP_X_VIEWER"] + end + RoomActor.ensure_registered! + end + + test "renders the latest collection addition removal and ordering" do + reference = RoomActor.ref("general") + reference.replace_messages( + messages: [ + { id: "1", body: "First" }, + { id: "2", body: "Second" } + ] + ) + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + + render_component(token, viewer: "alice") + assert_ordered_messages("1", "2") + + reference.replace_messages( + messages: [ + { id: "3", body: "Third" }, + { id: "1", body: "First" } + ] + ) + render_component(token, viewer: "alice") + + assert_ordered_messages("3", "1") + refute_includes @response.body, "Second" + end + + test "renders one or several observable dependencies" do + reference = RoomActor.ref("general") + reference.update_room( + messages: [ { id: "1", body: "First" } ], + status: "closed" + ) + token = component_token( + reference, + component_name: "presence", + dependencies: %w[recent_messages status] + ) + + render_component(token, viewer: "alice") + + assert_response :success + assert_includes @response.body, "

      Room closed

      " + end + + test "updates an ERB conditional after its dependency changes" do + reference = RoomActor.ref("general") + reference.update_room(messages: [], status: "open") + token = component_token( + reference, + component_name: "presence", + dependencies: %w[recent_messages status] + ) + + render_component(token, viewer: "alice") + assert_includes @response.body, "

      0 present

      " + + reference.update_room(messages: [], status: "closed") + render_component(token, viewer: "alice") + assert_includes @response.body, "

      Room closed

      " + end + + test "reauthorizes every component refresh" do + reference = RoomActor.ref("general") + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + SolidObjects.configuration.authorize_query = ->(**) { false } + + render_component(token, viewer: "alice") + + assert_response :forbidden + assert_empty @response.body + end + + test "reauthorizes the component name before refresh dependencies" do + reference = RoomActor.ref("general") + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + authorization_calls = [] + SolidObjects.configuration.authorize_query = lambda do |**arguments| + authorization_calls << arguments + arguments.fetch(:message_name) == "recent_messages" + end + + render_component(token, viewer: "alice") + + assert_response :forbidden + assert_equal [ "messages" ], + authorization_calls.map { |arguments| arguments.fetch(:message_name) } + end + + test "renders personalized HTML independently for each authorized request" do + reference = RoomActor.ref("general") + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + + render_component(token, viewer: "alice") + alice_html = @response.body + render_component(token, viewer: "bob") + bob_html = @response.body + + assert_includes alice_html, "

      alice

      " + refute_includes alice_html, "

      bob

      " + assert_includes bob_html, "

      bob

      " + refute_includes bob_html, "

      alice

      " + assert_equal "private, no-store", @response.headers["Cache-Control"] + end + + test "escapes user-provided strings through ERB" do + reference = RoomActor.ref("general") + reference.replace_messages( + messages: [ { id: "1", body: "" } ] + ) + token = component_token( + reference, + component_name: "messages", + dependencies: %w[recent_messages] + ) + + render_component(token, viewer: "alice") + + assert_includes @response.body, "<script>alert('x')</script>" + refute_includes @response.body, "