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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ac17dc212792f2dfa71c01e701a9e4de27f54b6198ec6e64696b855ff82c4c8
4
- data.tar.gz: 45bff1ddb2bbc0ff80ddfb55488fec0380d02c729e08a7f06b000b744dddb82d
3
+ metadata.gz: 6996e5e1ea8fa47b59b66cb6ed0f72e28d1673c2da5697e5507873db55f0b7ff
4
+ data.tar.gz: edb728dd6427ca4a1486dc791816b7a3ab8d3bdf822992c27d3800ab925c120e
5
5
  SHA512:
6
- metadata.gz: ffb590f2e07a0de4d4ba2ab5ac1cb7d76420b742d36d656b85ecdb0fc401dbabd92b75e47df73948f7e1d42fe4a289d2f16fa8d86086c8bff336ac4e33ae89dc
7
- data.tar.gz: 5a43e24c8358e2352de0ca1428cdc10d15bdf679c62ca1983b420fa1e739d0c975004e0e33ffb64a96fe3d09cbbcd30181db6a95a678c071fd80f85b935811cd
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;
@@ -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, :component_subscriptions, :scalar_observables
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
- if !invalidation ||
55
- scalar_observables.nil? ||
56
- scalar_observables.include?(invalidation.fetch("observable_name"))
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)
@@ -43,6 +43,12 @@ module SolidObjects
43
43
  class UnknownComponent < Error
44
44
  end
45
45
 
46
+ class UnknownPayloadBroadcast < Error
47
+ end
48
+
49
+ class InvalidPayloadBroadcast < Error
50
+ end
51
+
46
52
  class UnknownComponentDependency < Error
47
53
  end
48
54
 
@@ -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(locked_message, instance, observable_changes)
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
- observable_changes.each do |observable_name, value|
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
- observables = identity["observables"]
40
- return identity unless observables
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 = observables.is_a?(Array) &&
43
- observables.length <= MAXIMUM_OBSERVABLES &&
44
- observables.uniq.length == observables.length &&
45
- observables.all? do |observable|
46
- observable.is_a?(String) &&
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 identity if valid
55
+ return if valid
50
56
 
51
- raise InvalidStreamToken, "invalid actor stream observables"
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 = observable_value(
20
- reference,
21
- broadcast.observable_name,
22
- broadcast.value
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]
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.0"
5
5
  end
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
 
@@ -43,6 +43,12 @@ module SolidObjects
43
43
  class UnknownComponent < Error
44
44
  end
45
45
 
46
+ class UnknownPayloadBroadcast < Error
47
+ end
48
+
49
+ class InvalidPayloadBroadcast < Error
50
+ end
51
+
46
52
  class UnknownComponentDependency < Error
47
53
  end
48
54
 
@@ -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.5.2
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