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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +134 -25
- data/app/controllers/solid_objects/components_controller.rb +78 -0
- data/app/helpers/solid_objects/actor_helper.rb +13 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
- data/docs/architecture.md +61 -15
- data/docs/authorization.md +38 -1
- data/docs/correctness.md +28 -2
- data/docs/database-schema.md +13 -5
- data/docs/realtime.md +106 -12
- data/docs/roadmap.md +6 -5
- data/examples/application/README.md +5 -4
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
- data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
- data/examples/application/config/initializers/solid_objects.rb +9 -3
- data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
- data/lib/solid_objects/actor.rb +11 -0
- data/lib/solid_objects/actor_channel.rb +73 -5
- data/lib/solid_objects/actor_snapshot.rb +25 -6
- data/lib/solid_objects/actor_view.rb +103 -10
- data/lib/solid_objects/component_path_resolver.rb +27 -0
- data/lib/solid_objects/component_registration.rb +117 -0
- data/lib/solid_objects/component_renderer.rb +82 -0
- data/lib/solid_objects/component_subscriptions.rb +109 -0
- data/lib/solid_objects/component_token.rb +139 -0
- data/lib/solid_objects/component_view.rb +67 -0
- data/lib/solid_objects/configuration.rb +12 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
- data/lib/solid_objects/errors.rb +12 -0
- data/lib/solid_objects/executor.rb +1 -0
- data/lib/solid_objects/stream_token.rb +32 -13
- data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +6 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
- data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
- data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
- data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
- data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
- data/sig/generated/lib/solid_objects/errors.rbs +12 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
- metadata +16 -1
data/docs/realtime.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# Realtime integration
|
|
2
2
|
|
|
3
|
-
##
|
|
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)
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
30
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
|
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:
|
|
39
|
-
|
|
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
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Turbo append intents remain roadmap
|
|
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,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
|
-
|
|
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
|
-
|
|
13
|
+
user.id.to_s == actor_id
|
|
9
14
|
else
|
|
10
|
-
ChatRoomPolicy.new(
|
|
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
|
#
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
:
|
|
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,
|
|
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
|