solid_objects 0.5.2 → 0.6.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 +13 -0
- data/app/assets/javascripts/solid_objects/state_payload.js +69 -0
- data/app/helpers/solid_objects/actor_helper.rb +13 -4
- data/docs/realtime.md +87 -0
- data/lib/solid_objects/actor.rb +7 -0
- data/lib/solid_objects/actor_channel.rb +55 -4
- data/lib/solid_objects/actor_definition.rb +19 -0
- data/lib/solid_objects/errors.rb +6 -0
- data/lib/solid_objects/executor.rb +22 -7
- data/lib/solid_objects/payload_broadcast.rb +67 -0
- data/lib/solid_objects/stream_token.rb +19 -12
- data/lib/solid_objects/turbo_stream_renderer.rb +22 -5
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/helpers/solid_objects/actor_helper.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +11 -0
- data/sig/generated/lib/solid_objects/actor_definition.rbs +7 -0
- data/sig/generated/lib/solid_objects/errors.rbs +6 -0
- data/sig/generated/lib/solid_objects/executor.rbs +7 -4
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +35 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +5 -2
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6996e5e1ea8fa47b59b66cb6ed0f72e28d1673c2da5697e5507873db55f0b7ff
|
|
4
|
+
data.tar.gz: edb728dd6427ca4a1486dc791816b7a3ab8d3bdf822992c27d3800ab925c120e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 888370023e0d24e18eda279862a01a31b8583fccc5913fae571fa76d4001de1f408b22e7c3242c23975f01aeb293c47bd9314d94d98177e99c353fb78796caaa
|
|
7
|
+
data.tar.gz: 130982ddf19508a6268ae7356ac6249cd8efcc10cc29dd5c13e5cd865322540deabae688e511825a3a92bdc6bfe6314cf9f003d3ec2f4d273d968668438a1588
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-08-09
|
|
4
|
+
|
|
5
|
+
- Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
|
|
6
|
+
payload over the actor stream a page already has open. The block runs once per
|
|
7
|
+
subscriber with that subscriber's authorization context, so private state
|
|
8
|
+
never crosses sessions. Payloads carry actor identity and the monotonic state
|
|
9
|
+
revision, and both the channel and the browser drop stale revisions. Subscribe
|
|
10
|
+
with `solid_object room, payloads: :playmat_state` and handle the
|
|
11
|
+
`solid-objects:payload` DOM event. ERB component refreshes remain the default
|
|
12
|
+
and are unchanged. A mutation that changes payload state without changing a
|
|
13
|
+
declared observable still invalidates subscribers, through a revision-only
|
|
14
|
+
broadcast that carries no observable value to the browser.
|
|
15
|
+
|
|
3
16
|
## 0.5.2 - 2026-08-09
|
|
4
17
|
|
|
5
18
|
- Read the database clock once per transaction instead of once per step, and
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const deliveredRevisions = new Map()
|
|
2
|
+
|
|
3
|
+
class SolidObjectsPayloadElement extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
if (this.dataset.started === "true") return
|
|
6
|
+
|
|
7
|
+
this.dataset.started = "true"
|
|
8
|
+
this.deliver()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
deliver() {
|
|
12
|
+
try {
|
|
13
|
+
const name = this.dataset.name
|
|
14
|
+
const revision = revisionFor(this)
|
|
15
|
+
const scope = this.closest("[id]")
|
|
16
|
+
if (!name || !revision || !scope) return
|
|
17
|
+
|
|
18
|
+
const key = `${scope.id}:${name}`
|
|
19
|
+
if (!newerRevision(revision, deliveredRevisions.get(key))) return
|
|
20
|
+
|
|
21
|
+
const payload = JSON.parse(this.textContent)
|
|
22
|
+
deliveredRevisions.set(key, revision)
|
|
23
|
+
scope.dispatchEvent(
|
|
24
|
+
new CustomEvent("solid-objects:payload", {
|
|
25
|
+
bubbles: true,
|
|
26
|
+
detail: {
|
|
27
|
+
name,
|
|
28
|
+
instanceId: revision[0],
|
|
29
|
+
revision: revision[1],
|
|
30
|
+
payload
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
} catch {
|
|
35
|
+
this.dispatchEvent(
|
|
36
|
+
new CustomEvent("solid-objects:payload-error", {
|
|
37
|
+
bubbles: true,
|
|
38
|
+
detail: { reason: "invalid_payload" }
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
} finally {
|
|
42
|
+
this.remove()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function newerRevision(candidate, current) {
|
|
48
|
+
if (!current) return true
|
|
49
|
+
|
|
50
|
+
return candidate[0] > current[0] ||
|
|
51
|
+
(candidate[0] === current[0] && candidate[1] > current[1])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function revisionFor(element) {
|
|
55
|
+
const revision = element.dataset.revision
|
|
56
|
+
if (!revision) return
|
|
57
|
+
|
|
58
|
+
const values = revision.split(":").map(Number)
|
|
59
|
+
if (
|
|
60
|
+
values.length !== 2 ||
|
|
61
|
+
values.some((value) => !Number.isSafeInteger(value) || value < 0)
|
|
62
|
+
) return
|
|
63
|
+
|
|
64
|
+
return values
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!customElements.get("solid-objects-payload")) {
|
|
68
|
+
customElements.define("solid-objects-payload", SolidObjectsPayloadElement)
|
|
69
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module ActorHelper
|
|
5
|
-
# @rbs (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
-
def solid_object(reference, authorization_context: self, &block)
|
|
5
|
+
# @rbs (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
+
def solid_object(reference, authorization_context: self, payloads: nil, &block)
|
|
7
|
+
payload_names = Array(payloads).map(&:to_s).uniq.presence
|
|
7
8
|
actor = ActorView.new(
|
|
8
9
|
reference:,
|
|
9
10
|
view_context: self,
|
|
@@ -13,7 +14,8 @@ module SolidObjects
|
|
|
13
14
|
subscription_data = {
|
|
14
15
|
token: StreamToken.generate(
|
|
15
16
|
reference,
|
|
16
|
-
observables: actor.scalar_observable_names
|
|
17
|
+
observables: actor.scalar_observable_names,
|
|
18
|
+
payloads: payload_names
|
|
17
19
|
)
|
|
18
20
|
}
|
|
19
21
|
if actor.component_tokens.any?
|
|
@@ -30,10 +32,17 @@ module SolidObjects
|
|
|
30
32
|
data: { turbo_track: "reload" }
|
|
31
33
|
)
|
|
32
34
|
end
|
|
35
|
+
payload_client = if payload_names
|
|
36
|
+
javascript_include_tag(
|
|
37
|
+
"solid_objects/state_payload",
|
|
38
|
+
type: "module",
|
|
39
|
+
data: { turbo_track: "reload" }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
33
42
|
|
|
34
43
|
content_tag(
|
|
35
44
|
:div,
|
|
36
|
-
safe_join([ refresh_client, subscription, content ].compact),
|
|
45
|
+
safe_join([ refresh_client, payload_client, subscription, content ].compact),
|
|
37
46
|
id: DomIdentity.scope(reference)
|
|
38
47
|
)
|
|
39
48
|
end
|
data/docs/realtime.md
CHANGED
|
@@ -110,6 +110,93 @@ applications discover the namespaced engine asset. Applications created with
|
|
|
110
110
|
explicitly serve the module. Turbo's normal morph rules still apply; use
|
|
111
111
|
`data-turbo-permanent` for elements that must never be changed.
|
|
112
112
|
|
|
113
|
+
## Personalized state payloads
|
|
114
|
+
|
|
115
|
+
Reactive ERB components cost one browser request per changed component. When a
|
|
116
|
+
single actor mutation changes several components, an application pays several
|
|
117
|
+
round trips for one logical update. A payload broadcast collapses that into one
|
|
118
|
+
message on the stream the page already has open.
|
|
119
|
+
|
|
120
|
+
Declare the payload on the actor. The block receives the actor and the
|
|
121
|
+
subscriber's authorization context, and it runs **once per subscriber**, so two
|
|
122
|
+
sessions watching the same actor never see each other's private state:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class PlaymatRoom < SolidObjects::Actor
|
|
126
|
+
actor_type "playmat_room"
|
|
127
|
+
|
|
128
|
+
attribute :hands, default: -> { {} }
|
|
129
|
+
attribute :turn, default: 1
|
|
130
|
+
|
|
131
|
+
observable :turn
|
|
132
|
+
|
|
133
|
+
broadcast_payload :playmat_state do |room, authorization_context|
|
|
134
|
+
{
|
|
135
|
+
"turn" => room.turn,
|
|
136
|
+
"hand" => room.hands.fetch(authorization_context.session_id, [])
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Subscribe the scope to it:
|
|
143
|
+
|
|
144
|
+
```erb
|
|
145
|
+
<%= solid_object room, payloads: :playmat_state do |actor| %>
|
|
146
|
+
<div data-playmat></div>
|
|
147
|
+
<% end %>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Handle it with any JavaScript. The gem dispatches a DOM event and requires no
|
|
151
|
+
framework:
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
document.addEventListener("solid-objects:payload", (event) => {
|
|
155
|
+
const { name, revision, payload } = event.detail
|
|
156
|
+
if (name !== "playmat_state") return
|
|
157
|
+
|
|
158
|
+
renderPlaymat(payload)
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### What the protocol guarantees
|
|
163
|
+
|
|
164
|
+
The payload travels as a Turbo Stream element on the existing actor stream, so
|
|
165
|
+
applications do not run a second WebSocket system. Each message carries the
|
|
166
|
+
actor identity plus the `instance_id` and monotonic `state_revision` that fence
|
|
167
|
+
component refreshes, and both the channel and the browser drop a payload that
|
|
168
|
+
is not newer than the last one delivered for that scope and name. A reconnecting
|
|
169
|
+
client receives the current payload on subscribe.
|
|
170
|
+
|
|
171
|
+
Authorization is the same `authorize_query` boundary that components use, called
|
|
172
|
+
with the payload name and the subscriber's Cable connection. A subscriber that
|
|
173
|
+
fails the check is skipped rather than served a partial payload, and the payload
|
|
174
|
+
name is signed into the stream token, so a browser cannot ask for a payload the
|
|
175
|
+
server did not offer.
|
|
176
|
+
|
|
177
|
+
Payload blocks read committed actor state through the same snapshot components
|
|
178
|
+
use. They cannot write application records, and the return value must be a JSON
|
|
179
|
+
object or array so the wire format stays inspectable.
|
|
180
|
+
|
|
181
|
+
### mtg-playmat before and after
|
|
182
|
+
|
|
183
|
+
Before, one mutation that touched three observables produced three refresh
|
|
184
|
+
elements and three HTTP requests:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 renders
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
After, the same mutation delivers one personalized payload and the page renders
|
|
191
|
+
once:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Components remain the default. An actor with no `broadcast_payload` and a scope
|
|
198
|
+
with no `payloads:` option behave exactly as before.
|
|
199
|
+
|
|
113
200
|
## Authorization
|
|
114
201
|
|
|
115
202
|
The HTML contains a signed actor identity token. Signing prevents modification;
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -57,6 +57,13 @@ module SolidObjects
|
|
|
57
57
|
definition.add_observable(name, block)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
61
|
+
def broadcast_payload(name, &block)
|
|
62
|
+
raise InvalidActor, "payload broadcasts require a block" unless block
|
|
63
|
+
|
|
64
|
+
definition.add_payload_broadcast(name, block)
|
|
65
|
+
end
|
|
66
|
+
|
|
60
67
|
# @rbs (?Integer) -> Integer
|
|
61
68
|
def state_version(version = nil)
|
|
62
69
|
definition.set_state_version(version) if version
|
|
@@ -19,7 +19,9 @@ module SolidObjects
|
|
|
19
19
|
|
|
20
20
|
@reference = Reference.new(actor_type:, actor_id:)
|
|
21
21
|
@scalar_observables = identity["observables"]
|
|
22
|
+
@payload_names = identity["payloads"]
|
|
22
23
|
validate_scalar_observables!
|
|
24
|
+
validate_payload_names!
|
|
23
25
|
@component_subscriptions = ComponentSubscriptions.parse(
|
|
24
26
|
params["components"],
|
|
25
27
|
reference:
|
|
@@ -36,6 +38,7 @@ module SolidObjects
|
|
|
36
38
|
)
|
|
37
39
|
end
|
|
38
40
|
refresh_outdated_components(snapshot)
|
|
41
|
+
transmit_state_payloads(snapshot)
|
|
39
42
|
rescue KeyError,
|
|
40
43
|
JSON::ParserError,
|
|
41
44
|
InvalidStreamToken,
|
|
@@ -46,14 +49,20 @@ module SolidObjects
|
|
|
46
49
|
|
|
47
50
|
private
|
|
48
51
|
|
|
49
|
-
attr_reader :reference,
|
|
52
|
+
attr_reader :reference,
|
|
53
|
+
:component_subscriptions,
|
|
54
|
+
:scalar_observables,
|
|
55
|
+
:payload_names
|
|
50
56
|
|
|
51
57
|
# @rbs (String) -> void
|
|
52
58
|
def receive_broadcast(stream)
|
|
53
59
|
invalidation = TurboStreamRenderer.invalidation(stream)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
revision_only = invalidation &&
|
|
61
|
+
invalidation.fetch("observable_name") == PayloadBroadcast::REVISION_OBSERVABLE
|
|
62
|
+
if !revision_only &&
|
|
63
|
+
(!invalidation ||
|
|
64
|
+
scalar_observables.nil? ||
|
|
65
|
+
scalar_observables.include?(invalidation.fetch("observable_name")))
|
|
57
66
|
transmit stream
|
|
58
67
|
end
|
|
59
68
|
return unless invalidation
|
|
@@ -61,6 +70,48 @@ module SolidObjects
|
|
|
61
70
|
component_subscriptions
|
|
62
71
|
.refreshes_for(invalidation)
|
|
63
72
|
.each { |refresh| transmit refresh }
|
|
73
|
+
transmit_state_payloads(ActorSnapshot.new(reference))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs (ActorSnapshot) -> void
|
|
77
|
+
def transmit_state_payloads(snapshot)
|
|
78
|
+
return if payload_names.nil? || payload_names.empty?
|
|
79
|
+
return unless newer_payload_revision?(snapshot)
|
|
80
|
+
|
|
81
|
+
payload_names.each do |name|
|
|
82
|
+
payload = PayloadBroadcast.new(
|
|
83
|
+
snapshot:,
|
|
84
|
+
name:,
|
|
85
|
+
authorization_context: connection
|
|
86
|
+
).call
|
|
87
|
+
transmit TurboStreamRenderer.state_payload(payload)
|
|
88
|
+
rescue Unauthorized
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
@payload_revision = [ snapshot.instance_id, snapshot.revision ]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @rbs (ActorSnapshot) -> bool
|
|
95
|
+
def newer_payload_revision?(snapshot)
|
|
96
|
+
current = @payload_revision
|
|
97
|
+
return true unless current
|
|
98
|
+
|
|
99
|
+
(current <=> [ snapshot.instance_id, snapshot.revision ]) == -1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @rbs () -> void
|
|
103
|
+
def validate_payload_names!
|
|
104
|
+
return unless payload_names
|
|
105
|
+
|
|
106
|
+
broadcasts = SolidObjects
|
|
107
|
+
.registry
|
|
108
|
+
.fetch(reference.actor_type)
|
|
109
|
+
.definition
|
|
110
|
+
.payload_broadcasts
|
|
111
|
+
unknown = payload_names.find { |name| !broadcasts.key?(name.to_sym) }
|
|
112
|
+
return unless unknown
|
|
113
|
+
|
|
114
|
+
raise InvalidStreamToken, "unknown payload broadcast #{unknown.inspect}"
|
|
64
115
|
end
|
|
65
116
|
|
|
66
117
|
# @rbs (ActorSnapshot) -> void
|
|
@@ -9,6 +9,7 @@ module SolidObjects
|
|
|
9
9
|
# @rbs @messages: Hash[Symbol, Handler]
|
|
10
10
|
# @rbs @queries: Hash[Symbol, Handler]
|
|
11
11
|
# @rbs @observables: Hash[Symbol, Handler]
|
|
12
|
+
# @rbs @payload_broadcasts: Hash[Symbol, Handler]
|
|
12
13
|
# @rbs @state_version: Integer
|
|
13
14
|
# @rbs @state_migrations: Array[StateMigration]
|
|
14
15
|
# @rbs @activation_hooks: Array[Proc]
|
|
@@ -20,6 +21,7 @@ module SolidObjects
|
|
|
20
21
|
:messages,
|
|
21
22
|
:queries,
|
|
22
23
|
:observables,
|
|
24
|
+
:payload_broadcasts,
|
|
23
25
|
:state_version,
|
|
24
26
|
:state_migrations,
|
|
25
27
|
:activation_hooks,
|
|
@@ -31,6 +33,7 @@ module SolidObjects
|
|
|
31
33
|
@messages = {}
|
|
32
34
|
@queries = {}
|
|
33
35
|
@observables = {}
|
|
36
|
+
@payload_broadcasts = {}
|
|
34
37
|
@state_version = 1
|
|
35
38
|
@state_migrations = []
|
|
36
39
|
@activation_hooks = []
|
|
@@ -83,6 +86,21 @@ module SolidObjects
|
|
|
83
86
|
end
|
|
84
87
|
end
|
|
85
88
|
|
|
89
|
+
# @rbs (Symbol | String, Proc) -> Handler
|
|
90
|
+
def add_payload_broadcast(name, block)
|
|
91
|
+
payload_name = name.to_sym
|
|
92
|
+
if payload_broadcasts.key?(payload_name)
|
|
93
|
+
raise InvalidActor, "#{payload_name.inspect} payload broadcast is already defined"
|
|
94
|
+
end
|
|
95
|
+
unless payload_name.to_s.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
96
|
+
raise InvalidActor, "payload broadcast names may contain only letters, digits, and underscores"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Handler.new(name: payload_name, block:).tap do |handler|
|
|
100
|
+
payload_broadcasts[payload_name] = handler
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
86
104
|
# @rbs (Class) -> ActorDefinition
|
|
87
105
|
def synchronize_instance_messages(actor_class)
|
|
88
106
|
names = actor_message_method_names(actor_class)
|
|
@@ -149,6 +167,7 @@ module SolidObjects
|
|
|
149
167
|
copy.instance_variable_set(:@messages, messages.dup)
|
|
150
168
|
copy.instance_variable_set(:@queries, queries.dup)
|
|
151
169
|
copy.instance_variable_set(:@observables, observables.dup)
|
|
170
|
+
copy.instance_variable_set(:@payload_broadcasts, payload_broadcasts.dup)
|
|
152
171
|
copy.instance_variable_set(:@state_version, state_version)
|
|
153
172
|
copy.instance_variable_set(:@state_migrations, state_migrations.dup)
|
|
154
173
|
copy.instance_variable_set(:@activation_hooks, activation_hooks.dup)
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -27,7 +27,7 @@ module SolidObjects
|
|
|
27
27
|
result = invoke_actor(message_context)
|
|
28
28
|
ensure_query_did_not_mutate_state!(state_before)
|
|
29
29
|
observable_changes = changed_observables(observables_before, actor.observable_values)
|
|
30
|
-
complete(result, observable_changes)
|
|
30
|
+
complete(result, observable_changes, state_changed: actor.state.to_h != state_before)
|
|
31
31
|
true
|
|
32
32
|
rescue LostActivation
|
|
33
33
|
raise
|
|
@@ -75,8 +75,8 @@ module SolidObjects
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# @rbs (untyped, Hash[String, untyped]) -> void
|
|
79
|
-
def complete(result, observable_changes)
|
|
78
|
+
# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
|
|
79
|
+
def complete(result, observable_changes, state_changed:)
|
|
80
80
|
serialized_state = Serialization.dump(
|
|
81
81
|
actor.state.to_h,
|
|
82
82
|
max_bytes: SolidObjects.configuration.max_state_bytes
|
|
@@ -111,7 +111,12 @@ module SolidObjects
|
|
|
111
111
|
enqueued_effects.concat(
|
|
112
112
|
enqueue_actor_messages(locked_message, instance, outbound_message_intents)
|
|
113
113
|
)
|
|
114
|
-
enqueue_broadcasts(
|
|
114
|
+
enqueue_broadcasts(
|
|
115
|
+
locked_message,
|
|
116
|
+
instance,
|
|
117
|
+
observable_changes,
|
|
118
|
+
state_changed:
|
|
119
|
+
)
|
|
115
120
|
claimed_message.destroy!
|
|
116
121
|
end
|
|
117
122
|
|
|
@@ -249,9 +254,14 @@ module SolidObjects
|
|
|
249
254
|
end
|
|
250
255
|
end
|
|
251
256
|
|
|
252
|
-
# @rbs (Message, Instance, Hash[String, untyped]) -> void
|
|
253
|
-
def enqueue_broadcasts(locked_message, instance, observable_changes)
|
|
254
|
-
|
|
257
|
+
# @rbs (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
|
|
258
|
+
def enqueue_broadcasts(locked_message, instance, observable_changes, state_changed:)
|
|
259
|
+
broadcasts = observable_changes
|
|
260
|
+
if broadcasts.empty? && state_changed && payload_broadcasts?
|
|
261
|
+
broadcasts = { PayloadBroadcast::REVISION_OBSERVABLE => {} }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
broadcasts.each do |observable_name, value|
|
|
255
265
|
Broadcast.create!(
|
|
256
266
|
message: locked_message,
|
|
257
267
|
instance:,
|
|
@@ -266,6 +276,11 @@ module SolidObjects
|
|
|
266
276
|
end
|
|
267
277
|
end
|
|
268
278
|
|
|
279
|
+
# @rbs () -> bool
|
|
280
|
+
def payload_broadcasts?
|
|
281
|
+
actor.class.definition.payload_broadcasts.any?
|
|
282
|
+
end
|
|
283
|
+
|
|
269
284
|
# @rbs (Exception) -> void
|
|
270
285
|
def fail_message(error)
|
|
271
286
|
error_details = serialized_error(error)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class PayloadBroadcast
|
|
5
|
+
MAXIMUM_PAYLOAD_BYTES = 1_048_576
|
|
6
|
+
REVISION_OBSERVABLE = "solid_objects.revision"
|
|
7
|
+
|
|
8
|
+
# @rbs @snapshot: ActorSnapshot
|
|
9
|
+
# @rbs @name: String
|
|
10
|
+
# @rbs @authorization_context: untyped
|
|
11
|
+
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
# @rbs (snapshot: ActorSnapshot, name: String, authorization_context: untyped) -> void
|
|
15
|
+
def initialize(snapshot:, name:, authorization_context:)
|
|
16
|
+
@snapshot = snapshot
|
|
17
|
+
@name = name
|
|
18
|
+
@authorization_context = authorization_context
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @rbs () -> Hash[String, untyped]
|
|
22
|
+
def call
|
|
23
|
+
handler = snapshot.actor_class.definition.payload_broadcasts[name.to_sym]
|
|
24
|
+
raise UnknownPayloadBroadcast, "unknown payload broadcast #{name.inspect}" unless handler
|
|
25
|
+
|
|
26
|
+
authorize!
|
|
27
|
+
{
|
|
28
|
+
"actor_type" => snapshot.reference.actor_type,
|
|
29
|
+
"actor_id" => snapshot.reference.actor_id,
|
|
30
|
+
"name" => name,
|
|
31
|
+
"instance_id" => snapshot.instance_id,
|
|
32
|
+
"revision" => snapshot.revision,
|
|
33
|
+
"payload" => rendered_payload(handler)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :snapshot, :authorization_context
|
|
40
|
+
|
|
41
|
+
# @rbs (ActorDefinition::Handler) -> untyped
|
|
42
|
+
def rendered_payload(handler)
|
|
43
|
+
payload = Serialization.dump(
|
|
44
|
+
handler.block.call(snapshot.actor, authorization_context),
|
|
45
|
+
max_bytes: MAXIMUM_PAYLOAD_BYTES
|
|
46
|
+
)
|
|
47
|
+
return payload if payload.is_a?(Hash) || payload.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
raise InvalidPayloadBroadcast,
|
|
50
|
+
"payload broadcast #{name.inspect} must return a JSON object or array"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @rbs () -> void
|
|
54
|
+
def authorize!
|
|
55
|
+
authorized = SolidObjects.configuration.authorize_query.call(
|
|
56
|
+
actor_type: snapshot.reference.actor_type,
|
|
57
|
+
actor_id: snapshot.reference.actor_id,
|
|
58
|
+
message_name: name,
|
|
59
|
+
arguments: {},
|
|
60
|
+
authorization_context:
|
|
61
|
+
)
|
|
62
|
+
return if authorized
|
|
63
|
+
|
|
64
|
+
raise Unauthorized, "actor payload broadcast is not authorized"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -9,13 +9,14 @@ module SolidObjects
|
|
|
9
9
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
-
# @rbs (Reference, ?observables: Array[String]?) -> String
|
|
13
|
-
def generate(reference, observables: nil)
|
|
12
|
+
# @rbs (Reference, ?observables: Array[String]?, ?payloads: Array[String]?) -> String
|
|
13
|
+
def generate(reference, observables: nil, payloads: nil)
|
|
14
14
|
identity = {
|
|
15
15
|
"actor_type" => reference.actor_type,
|
|
16
16
|
"actor_id" => reference.actor_id
|
|
17
17
|
}
|
|
18
18
|
identity["observables"] = observables if observables
|
|
19
|
+
identity["payloads"] = payloads if payloads
|
|
19
20
|
validate_identity!(identity)
|
|
20
21
|
verifier.generate(identity, purpose: PURPOSE)
|
|
21
22
|
end
|
|
@@ -36,21 +37,27 @@ module SolidObjects
|
|
|
36
37
|
raise InvalidStreamToken, "invalid actor stream token"
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
validate_names!(identity["observables"], "observables")
|
|
41
|
+
validate_names!(identity["payloads"], "payloads")
|
|
42
|
+
identity
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs (untyped, String) -> void
|
|
46
|
+
def validate_names!(names, label)
|
|
47
|
+
return unless names
|
|
41
48
|
|
|
42
|
-
valid =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
observable.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
49
|
+
valid = names.is_a?(Array) &&
|
|
50
|
+
names.length <= MAXIMUM_OBSERVABLES &&
|
|
51
|
+
names.uniq.length == names.length &&
|
|
52
|
+
names.all? do |name|
|
|
53
|
+
name.is_a?(String) && name.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
48
54
|
end
|
|
49
|
-
return
|
|
55
|
+
return if valid
|
|
50
56
|
|
|
51
|
-
raise InvalidStreamToken, "invalid actor stream
|
|
57
|
+
raise InvalidStreamToken, "invalid actor stream #{label}"
|
|
52
58
|
end
|
|
53
59
|
private_class_method :validate_identity!
|
|
60
|
+
private_class_method :validate_names!
|
|
54
61
|
|
|
55
62
|
# @rbs () -> ActiveSupport::MessageVerifier
|
|
56
63
|
def verifier
|
|
@@ -16,11 +16,15 @@ module SolidObjects
|
|
|
16
16
|
actor_type: broadcast.instance.actor_type,
|
|
17
17
|
actor_id: broadcast.instance.actor_id
|
|
18
18
|
)
|
|
19
|
-
stream =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
stream = if broadcast.observable_name == PayloadBroadcast::REVISION_OBSERVABLE
|
|
20
|
+
""
|
|
21
|
+
else
|
|
22
|
+
observable_value(
|
|
23
|
+
reference,
|
|
24
|
+
broadcast.observable_name,
|
|
25
|
+
broadcast.value
|
|
26
|
+
)
|
|
27
|
+
end
|
|
24
28
|
metadata = Base64.urlsafe_encode64(
|
|
25
29
|
JSON.generate(
|
|
26
30
|
"instance_id" => broadcast.instance_id,
|
|
@@ -50,6 +54,19 @@ module SolidObjects
|
|
|
50
54
|
%(<turbo-stream action="replace" target="#{target}"><template><turbo-frame id="#{target}" src="#{source}" data-solid-objects-revision="#{revision_value}" data-solid-objects-refresh="replace"></turbo-frame></template></turbo-stream>)
|
|
51
55
|
end
|
|
52
56
|
|
|
57
|
+
# @rbs (Hash[String, untyped]) -> String
|
|
58
|
+
def state_payload(payload)
|
|
59
|
+
reference = Reference.new(
|
|
60
|
+
actor_type: payload.fetch("actor_type"),
|
|
61
|
+
actor_id: payload.fetch("actor_id")
|
|
62
|
+
)
|
|
63
|
+
scope = DomIdentity.scope(reference)
|
|
64
|
+
name = ERB::Util.html_escape(payload.fetch("name"))
|
|
65
|
+
revision = "#{payload.fetch("instance_id")}:#{payload.fetch("revision")}"
|
|
66
|
+
body = ERB::Util.html_escape(JSON.generate(payload.fetch("payload")))
|
|
67
|
+
%(<turbo-stream action="append" target="#{scope}"><template><solid-objects-payload data-name="#{name}" data-revision="#{revision}">#{body}</solid-objects-payload></template></turbo-stream>)
|
|
68
|
+
end
|
|
69
|
+
|
|
53
70
|
# @rbs (String) -> Hash[String, untyped]?
|
|
54
71
|
def invalidation(stream)
|
|
55
72
|
encoded = stream[INVALIDATION_PATTERN, 1]
|
data/lib/solid_objects.rb
CHANGED
|
@@ -40,6 +40,7 @@ require "solid_objects/component_registration"
|
|
|
40
40
|
require "solid_objects/component_subscriptions"
|
|
41
41
|
require "solid_objects/component_view"
|
|
42
42
|
require "solid_objects/component_renderer"
|
|
43
|
+
require "solid_objects/payload_broadcast"
|
|
43
44
|
require "solid_objects/state_snapshot"
|
|
44
45
|
require "solid_objects/actor_view"
|
|
45
46
|
require "solid_objects/actor_channel"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module ActorHelper
|
|
5
|
-
# @rbs (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
-
def solid_object: (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
|
|
5
|
+
# @rbs (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
|
|
6
|
+
def solid_object: (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
|
|
7
7
|
end
|
|
8
8
|
end
|
|
@@ -90,6 +90,9 @@ module SolidObjects
|
|
|
90
90
|
# @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
91
91
|
def self.observable: (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
92
92
|
|
|
93
|
+
# @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
94
|
+
def self.broadcast_payload: (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
|
|
95
|
+
|
|
93
96
|
# @rbs (?Integer) -> Integer
|
|
94
97
|
def self.state_version: (?Integer) -> Integer
|
|
95
98
|
|
|
@@ -13,9 +13,20 @@ module SolidObjects
|
|
|
13
13
|
|
|
14
14
|
attr_reader scalar_observables: untyped
|
|
15
15
|
|
|
16
|
+
attr_reader payload_names: untyped
|
|
17
|
+
|
|
16
18
|
# @rbs (String) -> void
|
|
17
19
|
def receive_broadcast: (String) -> void
|
|
18
20
|
|
|
21
|
+
# @rbs (ActorSnapshot) -> void
|
|
22
|
+
def transmit_state_payloads: (ActorSnapshot) -> void
|
|
23
|
+
|
|
24
|
+
# @rbs (ActorSnapshot) -> bool
|
|
25
|
+
def newer_payload_revision?: (ActorSnapshot) -> bool
|
|
26
|
+
|
|
27
|
+
# @rbs () -> void
|
|
28
|
+
def validate_payload_names!: () -> void
|
|
29
|
+
|
|
19
30
|
# @rbs (ActorSnapshot) -> void
|
|
20
31
|
def refresh_outdated_components: (ActorSnapshot) -> void
|
|
21
32
|
|
|
@@ -42,6 +42,8 @@ module SolidObjects
|
|
|
42
42
|
|
|
43
43
|
@state_version: Integer
|
|
44
44
|
|
|
45
|
+
@payload_broadcasts: Hash[Symbol, Handler]
|
|
46
|
+
|
|
45
47
|
@observables: Hash[Symbol, Handler]
|
|
46
48
|
|
|
47
49
|
@queries: Hash[Symbol, Handler]
|
|
@@ -58,6 +60,8 @@ module SolidObjects
|
|
|
58
60
|
|
|
59
61
|
attr_reader observables: untyped
|
|
60
62
|
|
|
63
|
+
attr_reader payload_broadcasts: untyped
|
|
64
|
+
|
|
61
65
|
attr_reader state_version: untyped
|
|
62
66
|
|
|
63
67
|
attr_reader state_migrations: untyped
|
|
@@ -81,6 +85,9 @@ module SolidObjects
|
|
|
81
85
|
# @rbs (Symbol | String, Proc?) -> Handler
|
|
82
86
|
def add_observable: (Symbol | String, Proc?) -> Handler
|
|
83
87
|
|
|
88
|
+
# @rbs (Symbol | String, Proc) -> Handler
|
|
89
|
+
def add_payload_broadcast: (Symbol | String, Proc) -> Handler
|
|
90
|
+
|
|
84
91
|
# @rbs (Class) -> ActorDefinition
|
|
85
92
|
def synchronize_instance_messages: (Class) -> ActorDefinition
|
|
86
93
|
|
|
@@ -30,8 +30,8 @@ module SolidObjects
|
|
|
30
30
|
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
|
|
31
31
|
def changed_observables: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
|
|
32
32
|
|
|
33
|
-
# @rbs (untyped, Hash[String, untyped]) -> void
|
|
34
|
-
def complete: (untyped, Hash[String, untyped]) -> void
|
|
33
|
+
# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
|
|
34
|
+
def complete: (untyped, Hash[String, untyped], state_changed: bool) -> void
|
|
35
35
|
|
|
36
36
|
# @rbs (Array[Actor::CommitActionIntent]) -> void
|
|
37
37
|
def execute_commit_actions: (Array[Actor::CommitActionIntent]) -> void
|
|
@@ -51,8 +51,11 @@ module SolidObjects
|
|
|
51
51
|
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
52
52
|
def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
|
|
53
53
|
|
|
54
|
-
# @rbs (Message, Instance, Hash[String, untyped]) -> void
|
|
55
|
-
def enqueue_broadcasts: (Message, Instance, Hash[String, untyped]) -> void
|
|
54
|
+
# @rbs (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
|
|
55
|
+
def enqueue_broadcasts: (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
|
|
56
|
+
|
|
57
|
+
# @rbs () -> bool
|
|
58
|
+
def payload_broadcasts?: () -> bool
|
|
56
59
|
|
|
57
60
|
# @rbs (Exception) -> void
|
|
58
61
|
def fail_message: (Exception) -> void
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/payload_broadcast.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class PayloadBroadcast
|
|
5
|
+
MAXIMUM_PAYLOAD_BYTES: ::Integer
|
|
6
|
+
|
|
7
|
+
REVISION_OBSERVABLE: ::String
|
|
8
|
+
|
|
9
|
+
@snapshot: ActorSnapshot
|
|
10
|
+
|
|
11
|
+
@name: String
|
|
12
|
+
|
|
13
|
+
@authorization_context: untyped
|
|
14
|
+
|
|
15
|
+
attr_reader name: untyped
|
|
16
|
+
|
|
17
|
+
# @rbs (snapshot: ActorSnapshot, name: String, authorization_context: untyped) -> void
|
|
18
|
+
def initialize: (snapshot: ActorSnapshot, name: String, authorization_context: untyped) -> void
|
|
19
|
+
|
|
20
|
+
# @rbs () -> Hash[String, untyped]
|
|
21
|
+
def call: () -> Hash[String, untyped]
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader snapshot: untyped
|
|
26
|
+
|
|
27
|
+
attr_reader authorization_context: untyped
|
|
28
|
+
|
|
29
|
+
# @rbs (ActorDefinition::Handler) -> untyped
|
|
30
|
+
def rendered_payload: (ActorDefinition::Handler) -> untyped
|
|
31
|
+
|
|
32
|
+
# @rbs () -> void
|
|
33
|
+
def authorize!: () -> void
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -6,8 +6,8 @@ module SolidObjects
|
|
|
6
6
|
|
|
7
7
|
MAXIMUM_OBSERVABLES: ::Integer
|
|
8
8
|
|
|
9
|
-
# @rbs (Reference, ?observables: Array[String]?) -> String
|
|
10
|
-
def self?.generate: (Reference, ?observables: Array[String]?) -> String
|
|
9
|
+
# @rbs (Reference, ?observables: Array[String]?, ?payloads: Array[String]?) -> String
|
|
10
|
+
def self?.generate: (Reference, ?observables: Array[String]?, ?payloads: Array[String]?) -> String
|
|
11
11
|
|
|
12
12
|
# @rbs (String) -> Hash[String, untyped]
|
|
13
13
|
def self?.verify: (String) -> Hash[String, untyped]
|
|
@@ -15,6 +15,9 @@ module SolidObjects
|
|
|
15
15
|
# @rbs (Hash[String, untyped]) -> Hash[String, untyped]
|
|
16
16
|
def self?.validate_identity!: (Hash[String, untyped]) -> Hash[String, untyped]
|
|
17
17
|
|
|
18
|
+
# @rbs (untyped, String) -> void
|
|
19
|
+
def self?.validate_names!: (untyped, String) -> void
|
|
20
|
+
|
|
18
21
|
# @rbs () -> ActiveSupport::MessageVerifier
|
|
19
22
|
def self?.verifier: () -> ActiveSupport::MessageVerifier
|
|
20
23
|
|
|
@@ -15,6 +15,9 @@ module SolidObjects
|
|
|
15
15
|
# @rbs (ComponentRegistration, Integer, Integer) -> String
|
|
16
16
|
def self?.component_refresh: (ComponentRegistration, Integer, Integer) -> String
|
|
17
17
|
|
|
18
|
+
# @rbs (Hash[String, untyped]) -> String
|
|
19
|
+
def self?.state_payload: (Hash[String, untyped]) -> String
|
|
20
|
+
|
|
18
21
|
# @rbs (String) -> Hash[String, untyped]?
|
|
19
22
|
def self?.invalidation: (String) -> Hash[String, untyped]?
|
|
20
23
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -263,6 +263,7 @@ files:
|
|
|
263
263
|
- README.md
|
|
264
264
|
- Rakefile
|
|
265
265
|
- app/assets/javascripts/solid_objects/component_refresh.js
|
|
266
|
+
- app/assets/javascripts/solid_objects/state_payload.js
|
|
266
267
|
- app/controllers/solid_objects/application_controller.rb
|
|
267
268
|
- app/controllers/solid_objects/components_controller.rb
|
|
268
269
|
- app/controllers/solid_objects/dead_letters_controller.rb
|
|
@@ -382,6 +383,7 @@ files:
|
|
|
382
383
|
- lib/solid_objects/mailbox.rb
|
|
383
384
|
- lib/solid_objects/message_pruner.rb
|
|
384
385
|
- lib/solid_objects/message_reference.rb
|
|
386
|
+
- lib/solid_objects/payload_broadcast.rb
|
|
385
387
|
- lib/solid_objects/process_pruner.rb
|
|
386
388
|
- lib/solid_objects/process_registry.rb
|
|
387
389
|
- lib/solid_objects/reference.rb
|
|
@@ -452,6 +454,7 @@ files:
|
|
|
452
454
|
- sig/generated/lib/solid_objects/mailbox.rbs
|
|
453
455
|
- sig/generated/lib/solid_objects/message_pruner.rbs
|
|
454
456
|
- sig/generated/lib/solid_objects/message_reference.rbs
|
|
457
|
+
- sig/generated/lib/solid_objects/payload_broadcast.rbs
|
|
455
458
|
- sig/generated/lib/solid_objects/process_pruner.rbs
|
|
456
459
|
- sig/generated/lib/solid_objects/process_registry.rbs
|
|
457
460
|
- sig/generated/lib/solid_objects/reference.rbs
|