solid_objects 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +134 -25
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +61 -15
  9. data/docs/authorization.md +38 -1
  10. data/docs/correctness.md +28 -2
  11. data/docs/database-schema.md +13 -5
  12. data/docs/realtime.md +106 -12
  13. data/docs/roadmap.md +6 -5
  14. data/examples/application/README.md +5 -4
  15. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  16. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  17. data/examples/application/config/initializers/solid_objects.rb +9 -3
  18. data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
  19. data/lib/solid_objects/actor.rb +11 -0
  20. data/lib/solid_objects/actor_channel.rb +73 -5
  21. data/lib/solid_objects/actor_snapshot.rb +25 -6
  22. data/lib/solid_objects/actor_view.rb +103 -10
  23. data/lib/solid_objects/component_path_resolver.rb +27 -0
  24. data/lib/solid_objects/component_registration.rb +117 -0
  25. data/lib/solid_objects/component_renderer.rb +82 -0
  26. data/lib/solid_objects/component_subscriptions.rb +109 -0
  27. data/lib/solid_objects/component_token.rb +139 -0
  28. data/lib/solid_objects/component_view.rb +67 -0
  29. data/lib/solid_objects/configuration.rb +12 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
  31. data/lib/solid_objects/errors.rb +12 -0
  32. data/lib/solid_objects/executor.rb +1 -0
  33. data/lib/solid_objects/stream_token.rb +32 -13
  34. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  35. data/lib/solid_objects/version.rb +1 -1
  36. data/lib/solid_objects.rb +6 -0
  37. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  39. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  40. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  41. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  42. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  43. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  44. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  45. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  46. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  47. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  48. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  49. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
  50. data/sig/generated/lib/solid_objects/errors.rbs +12 -0
  51. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  52. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  53. metadata +16 -1
data/docs/realtime.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # Realtime integration
2
2
 
3
- ## Rendering
3
+ ## Scalars and components
4
4
 
5
5
  `solid_object` performs initial server rendering and emits one
6
6
  `turbo-cable-stream-source` for the actor:
7
7
 
8
8
  ```erb
9
- <%= solid_object ShoppingCartActor.ref(current_user.id) do |cart| %>
9
+ <%= solid_object ShoppingCartActor.ref(current_user.id),
10
+ authorization_context: current_user do |cart| %>
10
11
  Items: <%= cart.items_count %>
12
+ <%= cart.component :summary, observes: %i[items checkout_status] %>
11
13
  <% end %>
12
14
  ```
13
15
 
@@ -15,9 +17,39 @@ Every observable gets a stable opaque DOM ID. Multiple values share the one
15
17
  actor subscription and Action Cable multiplexes actor subscriptions over the
16
18
  browser's physical WebSocket.
17
19
 
18
- `actor.component(:summary)` renders a host partial by convention at
19
- `actors/<actor_class>/_summary`. Initial component rendering is implemented;
20
- durable background component replacement is not yet implemented.
20
+ Scalar observable calls such as `cart.items_count` render stable `<span>`
21
+ targets. Their broadcast remains a direct escaped text replacement.
22
+
23
+ A reactive component declares one or more explicit observable dependencies.
24
+ `actor.component(:summary, observes: ...)` resolves the host partial by
25
+ convention at `actors/<actor_class>/_summary` and wraps it in a stable Turbo
26
+ Frame. Reactive components do not accept `partial:` because a client must
27
+ never influence partial resolution. The older
28
+ `actor.component(:summary, partial: "server/chosen/path")` form remains
29
+ available for initial-only static rendering.
30
+
31
+ The partial receives exactly two component locals:
32
+
33
+ - `actor`, which exposes the declared observables as deeply frozen ordinary
34
+ Ruby values plus `actor_id` and `reference`; and
35
+ - `authorization_context`, the context for this initial render or refresh.
36
+
37
+ `actor.state` is unavailable in reactive components. Reading an observable not
38
+ listed in `observes:` raises `UnknownComponentDependency`. This keeps
39
+ invalidation correct and prevents a partial from silently depending on state
40
+ that cannot wake it.
41
+
42
+ ```erb
43
+ <ul>
44
+ <% actor.recent_messages.each do |message| %>
45
+ <li><%= message.fetch("body") %></li>
46
+ <% end %>
47
+ </ul>
48
+ ```
49
+
50
+ Arrays, hashes, loops, conditionals, nested markup, and host helper output are
51
+ normal ERB. Escaping remains Action View's responsibility; Solid Objects never
52
+ marks actor strings as HTML safe.
21
53
 
22
54
  ## Authorization
23
55
 
@@ -26,18 +58,69 @@ it does not grant access. `ActorChannel` verifies the token, resolves the actor
26
58
  through the registry, calls `authorize_subscription`, and streams only after
27
59
  approval.
28
60
 
29
- Initial observable method reads separately call `authorize_query`. Never
30
- authorize solely from actor ID, token possession, stream name, or DOM ID.
61
+ Initial scalar and component reads call `authorize_query` with the context
62
+ passed to `solid_object`. The refresh controller resolves a new request context
63
+ through `component_authorization_context`, then calls `authorize_query` again
64
+ for the component name and every declared dependency. The default resolver
65
+ supplies the engine controller; applications commonly resolve it to
66
+ `Current.user`:
67
+
68
+ ```ruby
69
+ configuration.component_authorization_context = ->(controller:) { Current.user }
70
+ ```
71
+
72
+ The three contexts are intentionally different:
73
+
74
+ | Boundary | Authorization context |
75
+ | --- | --- |
76
+ | Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
77
+ | Action Cable subscription | The authenticated Cable connection |
78
+ | Component refresh | Value returned by `component_authorization_context` for the engine controller request |
79
+
80
+ Do not substitute a signed token for any of them. Never authorize solely from
81
+ actor ID, token possession, stream name, component name, or DOM ID.
31
82
 
32
83
  ## Broadcast durability
33
84
 
34
85
  The actor's fenced commit compares observables before and after the turn and
35
- inserts one broadcast row per changed value. A broadcast process later sends a
36
- Turbo replacement and records delivery. No direct broadcast occurs inside the
37
- actor transaction.
86
+ inserts one broadcast row per changed value. The actor state, monotonic
87
+ `state_revision`, message completion, and broadcast rows commit atomically. A
88
+ rolled-back or fenced-out turn therefore cannot invalidate a component.
89
+
90
+ A broadcast process later sends small invalidation metadata and records
91
+ delivery. Its scalar Turbo replacement is transmitted only when that observable
92
+ target was rendered and signed into the scope's stream token. Component-only
93
+ dependencies therefore do not expose their serialized values over Cable. The
94
+ runtime never stores or broadcasts personalized component HTML. Each
95
+ authorized browser requests affected components through the engine endpoint
96
+ with its normal cookies. Responses are `private, no-store`.
97
+
98
+ Several changed dependencies from one message sequence produce one logical
99
+ refresh for a component. An unrelated observable does not refresh it. If a
100
+ newer invalidation arrives while a Turbo Frame request is in flight, the new
101
+ frame replaces the old frame element; the detached older response has no
102
+ current target.
38
103
 
39
104
  If Cable delivery is lost, reconnecting `ActorChannel` transmits replacements
40
- from current actor state. The durable state row remains source of truth.
105
+ from current actor state. It compares the signed component revision with the
106
+ latest `(instance_id, state_revision)` pair and refreshes stale components.
107
+ The incarnation ID handles destroy-and-recreate; the state revision handles
108
+ ordered commits within one incarnation. Out-of-order invalidations at or below
109
+ the last transmitted pair are ignored. The durable state row remains source of
110
+ truth.
111
+
112
+ The component endpoint rejects a requested revision newer than the committed
113
+ snapshot. This is a final server-side guard; browser safety primarily comes
114
+ from monotonic channel filtering and replacing the entire Turbo Frame
115
+ generation.
116
+
117
+ ## Cost model
118
+
119
+ The durable row cost is unchanged: one broadcast row per changed observable,
120
+ containing its JSON value and the message/instance references needed to derive
121
+ invalidation metadata. No rendered document is stored. Each affected component
122
+ adds one authorized GET and one partial render per non-coalesced state
123
+ revision. Scalar observables remain the cheaper path for one text value.
41
124
 
42
125
  ## Deployment
43
126
 
@@ -48,4 +131,15 @@ require Redis.
48
131
 
49
132
  Changing or removing observable names during a rolling deploy can strand old
50
133
  broadcast rows or old DOM targets. Keep old names compatible until the outbox
51
- and old pages have drained.
134
+ and old pages have drained. Keep component partial names and dependency
135
+ observables compatible across a rolling deploy for the same reason.
136
+
137
+ Reactive components require the engine mount because their signed refresh path
138
+ is generated from that mount. Applications with more than one engine mount can
139
+ set `component_path_resolver` to return the intended same-origin
140
+ `components_path`.
141
+
142
+ ```ruby
143
+ # config/routes.rb
144
+ mount SolidObjects::Engine => "/solid_objects"
145
+ ```
data/docs/roadmap.md CHANGED
@@ -15,7 +15,8 @@
15
15
  - Transactional effects with success/failure actor messages
16
16
  - Actor-to-actor asynchronous outbox delivery
17
17
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
18
- - Durable observable broadcast outbox and authorized Action Cable refresh
18
+ - Durable observable invalidations, scalar Turbo replacement, and authorized
19
+ request-time ERB component refresh
19
20
  - Reconciliation read APIs
20
21
  - Installation doctor, authorization reference, fit guide, and legacy-state
21
22
  migration cookbook
@@ -35,8 +36,9 @@
35
36
  role or run periodic maintenance automatically.
36
37
  - Wake-up strategy: in-process signaling plus durable polling and injection are
37
38
  implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
38
- - Realtime: observable replacement and reconnect refresh are implemented;
39
- durable component replacement and Turbo append actions are not.
39
+ - Realtime: scalar and dependency-driven ERB component replacement,
40
+ personalized refresh authorization, revision fencing, coalescing, and
41
+ reconnect convergence are implemented; Turbo append actions are not.
40
42
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
41
43
  distributed per-actor rate limits and global admission control do not.
42
44
  - Administration: actor and dead-letter views plus policy hooks exist; richer
@@ -52,8 +54,7 @@
52
54
  3. Add result lookup by request ID and broader deadlock retry classification.
53
55
  4. Add scheduled retention and stale-process maintenance.
54
56
  5. Add database/server-version checks and MySQL InnoDB verification at boot.
55
- 6. Add component broadcast rendering, Turbo append intents, and reconnect tests
56
- in a full browser.
57
+ 6. Add Turbo append intents and expand reconnect coverage in a full browser.
57
58
  7. Add distributed rate limits, global admission hooks, and cache-capacity
58
59
  eviction.
59
60
  8. Expand security scanning and run compatibility CI across supported Rails and
@@ -10,7 +10,8 @@ key. The chat actor also gives every submitted chat message a caller-generated
10
10
  message ID and checks that ID in durable actor state, because actor handlers may
11
11
  be redelivered.
12
12
 
13
- The views demonstrate initial `solid_object` rendering and live observable
14
- replacement. Live cart-summary replacement and chat-message append are
15
- deliberately not shown as working behavior because component broadcasts and
16
- Turbo append intents remain roadmap items.
13
+ The views demonstrate scalar observable replacement and a live chat-message
14
+ ERB component. The chat component receives `recent_messages` as an ordinary
15
+ Ruby array, rerenders its `<ol>` after committed changes, and refreshes through
16
+ the authenticated host request context. Turbo append intents remain roadmap
17
+ work.
@@ -1,5 +1,5 @@
1
1
  <ol>
2
- <% actor.state.recent_messages.each do |message| %>
2
+ <% actor.recent_messages.each do |message| %>
3
3
  <li id="message_<%= message.fetch("id") %>">
4
4
  <strong><%= message.fetch("user_id") %></strong>
5
5
  <%= message.fetch("body") %>
@@ -1,8 +1,8 @@
1
- <%= solid_object @room do |room| %>
1
+ <%= solid_object @room, authorization_context: current_user do |room| %>
2
2
  <p>
3
3
  Present:
4
4
  <%= room.presence %>
5
5
  </p>
6
6
 
7
- <%= room.component :messages %>
7
+ <%= room.component :messages, observes: :recent_messages %>
8
8
  <% end %>
@@ -2,16 +2,22 @@
2
2
 
3
3
  SolidObjects.configure do |configuration|
4
4
  configuration.authorize_message = lambda do |actor_type:, actor_id:, authorization_context:, **|
5
- next false unless authorization_context.respond_to?(:current_user)
5
+ user = if authorization_context.respond_to?(:current_user)
6
+ authorization_context.current_user
7
+ else
8
+ authorization_context
9
+ end
10
+ next false unless user
6
11
 
7
12
  if actor_type == ShoppingCartActor.actor_type
8
- authorization_context.current_user.id.to_s == actor_id
13
+ user.id.to_s == actor_id
9
14
  else
10
- ChatRoomPolicy.new(authorization_context.current_user).access?(actor_id)
15
+ ChatRoomPolicy.new(user).access?(actor_id)
11
16
  end
12
17
  end
13
18
  configuration.authorize_query = configuration.authorize_message
14
19
  configuration.authorize_subscription = configuration.authorize_message
20
+ configuration.component_authorization_context = ->(controller:) { Current.user }
15
21
  end
16
22
 
17
23
  SolidObjects.register_effect(:charge_payment) do |arguments, context|
@@ -49,6 +49,9 @@ SolidObjects.configure do |configuration|
49
49
  configuration.authorize_subscription = ->(**) { false }
50
50
  configuration.authorize_administration = ->(**) { false }
51
51
 
52
+ # Configure component_authorization_context to return the authenticated
53
+ # principal used for reactive component refreshes.
54
+
52
55
  # On hosts where shell access is already an authenticated administrative
53
56
  # boundary, this enables only gem commands that pass the CLI context:
54
57
  #
@@ -238,6 +238,17 @@ module SolidObjects
238
238
  end
239
239
  end
240
240
 
241
+ # @rbs (Symbol | String) -> untyped
242
+ def observable_value(name)
243
+ observable_name = name.to_sym
244
+ handler = self.class.definition.observables[observable_name]
245
+ raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
246
+
247
+ guard_application_writes("observable.#{observable_name}") do
248
+ Serialization.dump(instance_exec(&handler.block))
249
+ end
250
+ end
251
+
241
252
  # @rbs () -> void
242
253
  def activate
243
254
  guard_application_writes("on_activate") do
@@ -17,13 +17,81 @@ module SolidObjects
17
17
  )
18
18
  return reject unless authorized
19
19
 
20
- reference = Reference.new(actor_type:, actor_id:)
21
- stream_from StreamName.for(reference)
22
- ActorSnapshot.new(reference).observable_values.each do |name, value|
23
- transmit TurboStreamRenderer.observable_value(reference, name, value)
20
+ @reference = Reference.new(actor_type:, actor_id:)
21
+ @scalar_observables = identity["observables"]
22
+ validate_scalar_observables!
23
+ @component_subscriptions = ComponentSubscriptions.parse(
24
+ params["components"],
25
+ reference:
26
+ )
27
+ stream_from StreamName.for(reference) do |stream|
28
+ receive_broadcast(stream)
29
+ end
30
+ snapshot = ActorSnapshot.new(reference)
31
+ scalar_observable_names(snapshot).each do |name|
32
+ transmit TurboStreamRenderer.observable_value(
33
+ reference,
34
+ name,
35
+ snapshot.observable_value(name)
36
+ )
24
37
  end
25
- rescue KeyError, InvalidStreamToken, UnknownActorType
38
+ refresh_outdated_components(snapshot)
39
+ rescue KeyError,
40
+ JSON::ParserError,
41
+ InvalidStreamToken,
42
+ InvalidComponentToken,
43
+ UnknownActorType
26
44
  reject
27
45
  end
46
+
47
+ private
48
+
49
+ attr_reader :reference, :component_subscriptions, :scalar_observables
50
+
51
+ # @rbs (String) -> void
52
+ def receive_broadcast(stream)
53
+ invalidation = TurboStreamRenderer.invalidation(stream)
54
+ if !invalidation ||
55
+ scalar_observables.nil? ||
56
+ scalar_observables.include?(invalidation.fetch("observable_name"))
57
+ transmit stream
58
+ end
59
+ return unless invalidation
60
+
61
+ component_subscriptions
62
+ .refreshes_for(invalidation)
63
+ .each { |refresh| transmit refresh }
64
+ end
65
+
66
+ # @rbs (ActorSnapshot) -> void
67
+ def refresh_outdated_components(snapshot)
68
+ component_subscriptions
69
+ .reconnect_refreshes(snapshot)
70
+ .each { |refresh| transmit refresh }
71
+ end
72
+
73
+ # @rbs () -> void
74
+ def validate_scalar_observables!
75
+ return unless scalar_observables
76
+
77
+ observables = SolidObjects
78
+ .registry
79
+ .fetch(reference.actor_type)
80
+ .definition
81
+ .observables
82
+ unknown = scalar_observables.find do |name|
83
+ !observables.key?(name.to_sym)
84
+ end
85
+ return unless unknown
86
+
87
+ raise InvalidStreamToken, "unknown scalar observable #{unknown.inspect}"
88
+ end
89
+
90
+ # @rbs (ActorSnapshot) -> Array[String]
91
+ def scalar_observable_names(snapshot)
92
+ return scalar_observables if scalar_observables
93
+
94
+ snapshot.actor_class.definition.observables.keys.map(&:to_s)
95
+ end
28
96
  end
29
97
  end
@@ -5,29 +5,48 @@ module SolidObjects
5
5
  # @rbs @reference: Reference
6
6
  # @rbs @actor_class: Class
7
7
  # @rbs @actor: Actor
8
+ # @rbs @instance_id: Integer
9
+ # @rbs @revision: Integer
10
+ # @rbs @observable_values: Hash[String, untyped]?
11
+ # @rbs @observable_value_cache: Hash[String, untyped]
8
12
 
9
- attr_reader :reference, :actor_class, :actor
13
+ attr_reader :reference, :actor_class, :actor, :instance_id, :revision
10
14
 
11
15
  # @rbs (Reference) -> void
12
16
  def initialize(reference)
13
17
  @reference = reference
14
18
  @actor_class = SolidObjects.registry.fetch(reference.actor_type)
19
+ @instance = Instance.find_by(
20
+ actor_type: reference.actor_type,
21
+ actor_id: reference.actor_id
22
+ )
23
+ @instance_id = @instance&.id || 0
24
+ @revision = @instance&.state_revision || 0
15
25
  @actor = build_actor
26
+ @observable_values = nil
27
+ @observable_value_cache = {}
16
28
  end
17
29
 
18
30
  # @rbs () -> Hash[String, untyped]
19
31
  def observable_values
20
- actor.observable_values
32
+ @observable_values ||= Serialization.readonly_copy(actor.observable_values)
33
+ end
34
+
35
+ # @rbs (Symbol | String) -> untyped
36
+ def observable_value(name)
37
+ observable_name = name.to_s
38
+ return @observable_value_cache.fetch(observable_name) if @observable_value_cache.key?(observable_name)
39
+
40
+ @observable_value_cache[observable_name] =
41
+ Serialization.readonly_copy(actor.observable_value(observable_name))
21
42
  end
22
43
 
23
44
  private
24
45
 
46
+ attr_reader :instance
47
+
25
48
  # @rbs () -> Actor
26
49
  def build_actor
27
- instance = Instance.find_by(
28
- actor_type: reference.actor_type,
29
- actor_id: reference.actor_id
30
- )
31
50
  state_version = instance&.state_version || actor_class.state_version
32
51
  state_data = ApplicationWriteGuard.call(
33
52
  actor_type: reference.actor_type,
@@ -6,6 +6,8 @@ module SolidObjects
6
6
  # @rbs @view_context: untyped
7
7
  # @rbs @authorization_context: untyped
8
8
  # @rbs @snapshot: ActorSnapshot
9
+ # @rbs @component_registrations: Array[ComponentRegistration]
10
+ # @rbs @observable_names: Array[String]
9
11
 
10
12
  attr_reader :reference
11
13
 
@@ -15,6 +17,8 @@ module SolidObjects
15
17
  @view_context = view_context
16
18
  @authorization_context = authorization_context
17
19
  @snapshot = ActorSnapshot.new(reference)
20
+ @component_registrations = []
21
+ @observable_names = []
18
22
  end
19
23
 
20
24
  # @rbs (Symbol | String) -> untyped
@@ -24,7 +28,8 @@ module SolidObjects
24
28
  raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
25
29
 
26
30
  authorize_read!(observable_name)
27
- value = snapshot.observable_values.fetch(observable_name.to_s)
31
+ value = snapshot.observable_value(observable_name)
32
+ observable_names << observable_name.to_s unless observable_names.include?(observable_name.to_s)
28
33
  view_context.content_tag(
29
34
  :span,
30
35
  display_value(value),
@@ -32,21 +37,54 @@ module SolidObjects
32
37
  )
33
38
  end
34
39
 
35
- # @rbs (Symbol | String, ?partial: String?) -> untyped
36
- def component(name, partial: nil)
40
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
41
+ def component(name, observes: nil, partial: nil)
37
42
  component_name = normalized_component_name(name)
38
- authorize_read!(component_name)
39
- rendered = view_context.render(
40
- partial: partial || default_partial(component_name),
41
- locals: { actor: self }
43
+ return static_component(component_name, partial:) unless observes
44
+ if partial
45
+ raise ArgumentError,
46
+ "reactive components resolve partials by actor and component name"
47
+ end
48
+
49
+ dependencies = normalized_dependencies(observes)
50
+ validate_dependencies!(dependencies)
51
+ ensure_unique_component!(component_name)
52
+ refresh_path = component_path_resolver.call(view_context:)
53
+ registration = ComponentRegistration.issue(
54
+ reference:,
55
+ component_name:,
56
+ dependencies:,
57
+ snapshot:,
58
+ refresh_path:
42
59
  )
60
+ rendered = ComponentRenderer.new(
61
+ snapshot:,
62
+ component_name:,
63
+ dependencies:,
64
+ view_context:,
65
+ authorization_context:
66
+ ).call
67
+ component_registrations << registration
43
68
  view_context.content_tag(
44
- :div,
69
+ :"turbo-frame",
45
70
  rendered,
46
- id: DomIdentity.component(reference, component_name)
71
+ id: DomIdentity.component(reference, component_name),
72
+ data: {
73
+ solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}"
74
+ }
47
75
  )
48
76
  end
49
77
 
78
+ # @rbs () -> Array[String]
79
+ def component_tokens
80
+ component_registrations.map(&:token)
81
+ end
82
+
83
+ # @rbs () -> Array[String]
84
+ def scalar_observable_names
85
+ observable_names.dup
86
+ end
87
+
50
88
  # @rbs () -> State
51
89
  def state
52
90
  snapshot.actor.state
@@ -66,7 +104,25 @@ module SolidObjects
66
104
 
67
105
  private
68
106
 
69
- attr_reader :view_context, :authorization_context, :snapshot
107
+ attr_reader :view_context,
108
+ :authorization_context,
109
+ :snapshot,
110
+ :component_registrations,
111
+ :observable_names
112
+
113
+ # @rbs (String, partial: String?) -> untyped
114
+ def static_component(component_name, partial:)
115
+ authorize_read!(component_name)
116
+ rendered = view_context.render(
117
+ partial: partial || default_partial(component_name),
118
+ locals: { actor: self }
119
+ )
120
+ view_context.content_tag(
121
+ :div,
122
+ rendered,
123
+ id: DomIdentity.component(reference, component_name)
124
+ )
125
+ end
70
126
 
71
127
  # @rbs (Symbol | String) -> void
72
128
  def authorize_read!(name)
@@ -106,6 +162,43 @@ module SolidObjects
106
162
  component_name
107
163
  end
108
164
 
165
+ # @rbs (Symbol | String | Array[Symbol | String]) -> Array[String]
166
+ def normalized_dependencies(observes)
167
+ dependencies = Array(observes).map(&:to_s).uniq
168
+ if dependencies.empty?
169
+ raise ArgumentError, "reactive components require at least one observable"
170
+ end
171
+
172
+ dependencies
173
+ end
174
+
175
+ # @rbs (Array[String]) -> void
176
+ def validate_dependencies!(dependencies)
177
+ unknown = dependencies.find do |dependency|
178
+ !snapshot.actor_class.definition.observables.key?(dependency.to_sym)
179
+ end
180
+ return unless unknown
181
+
182
+ raise UnknownComponentDependency,
183
+ "unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
184
+ end
185
+
186
+ # @rbs (String) -> void
187
+ def ensure_unique_component!(component_name)
188
+ return unless component_registrations.any? do |registration|
189
+ registration.component_name == component_name
190
+ end
191
+
192
+ raise ArgumentError,
193
+ "component #{component_name.inspect} is already rendered in this solid_object scope"
194
+ end
195
+
196
+ # @rbs () -> Proc | ComponentPathResolver
197
+ def component_path_resolver
198
+ SolidObjects.configuration.component_path_resolver ||
199
+ ComponentPathResolver.new
200
+ end
201
+
109
202
  # @rbs (String) -> String
110
203
  def default_partial(component_name)
111
204
  actor_name = snapshot.actor_class.name
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ComponentPathResolver
5
+ # @rbs (view_context: untyped) -> String
6
+ def call(view_context:)
7
+ unless mounted?
8
+ raise UnsupportedComponentRendering,
9
+ "reactive components require the SolidObjects::Engine to be mounted in the host routes"
10
+ end
11
+
12
+ SolidObjects::Engine.routes.url_helpers.components_path
13
+ end
14
+
15
+ private
16
+
17
+ # @rbs () -> bool
18
+ def mounted?
19
+ return false unless defined?(Rails) && Rails.application
20
+
21
+ Rails.application.routes.routes.any? do |route|
22
+ route.app.respond_to?(:app) &&
23
+ route.app.app.equal?(SolidObjects::Engine)
24
+ end
25
+ end
26
+ end
27
+ end