solid_objects 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/app/assets/javascripts/solid_objects/component_batch_refresh.js +148 -0
  4. data/app/assets/javascripts/solid_objects/state_payload.js +69 -0
  5. data/app/controllers/solid_objects/components_controller.rb +84 -0
  6. data/app/helpers/solid_objects/actor_helper.rb +22 -4
  7. data/config/routes.rb +1 -0
  8. data/docs/realtime.md +161 -0
  9. data/lib/solid_objects/actor.rb +7 -0
  10. data/lib/solid_objects/actor_channel.rb +55 -4
  11. data/lib/solid_objects/actor_definition.rb +19 -0
  12. data/lib/solid_objects/actor_view.rb +14 -7
  13. data/lib/solid_objects/component_registration.rb +23 -4
  14. data/lib/solid_objects/component_subscriptions.rb +16 -10
  15. data/lib/solid_objects/component_token.rb +19 -2
  16. data/lib/solid_objects/errors.rb +6 -0
  17. data/lib/solid_objects/executor.rb +22 -7
  18. data/lib/solid_objects/payload_broadcast.rb +67 -0
  19. data/lib/solid_objects/stream_token.rb +19 -12
  20. data/lib/solid_objects/turbo_stream_renderer.rb +34 -5
  21. data/lib/solid_objects/version.rb +1 -1
  22. data/lib/solid_objects.rb +1 -0
  23. data/sig/generated/controllers/solid_objects/components_controller.rbs +11 -0
  24. data/sig/generated/helpers/solid_objects/actor_helper.rbs +2 -2
  25. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  26. data/sig/generated/lib/solid_objects/actor_channel.rbs +11 -0
  27. data/sig/generated/lib/solid_objects/actor_definition.rbs +7 -0
  28. data/sig/generated/lib/solid_objects/actor_view.rbs +7 -4
  29. data/sig/generated/lib/solid_objects/component_registration.rbs +11 -4
  30. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +3 -0
  31. data/sig/generated/lib/solid_objects/component_token.rbs +7 -2
  32. data/sig/generated/lib/solid_objects/errors.rbs +6 -0
  33. data/sig/generated/lib/solid_objects/executor.rbs +7 -4
  34. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +35 -0
  35. data/sig/generated/lib/solid_objects/stream_token.rbs +5 -2
  36. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +6 -0
  37. metadata +5 -1
@@ -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)
@@ -37,18 +37,19 @@ module SolidObjects
37
37
  )
38
38
  end
39
39
 
40
- # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol) -> untyped
40
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?, ?key: untyped, ?locals: Hash[untyped, untyped], ?refresh: String | Symbol, ?batch: untyped) -> untyped
41
41
  def component(
42
42
  name,
43
43
  observes: nil,
44
44
  partial: nil,
45
45
  key: nil,
46
46
  locals: {},
47
- refresh: :replace
47
+ refresh: :replace,
48
+ batch: nil
48
49
  )
49
50
  component_name = normalized_component_name(name)
50
51
  unless observes
51
- validate_static_options!(key:, locals:, refresh:)
52
+ validate_static_options!(key:, locals:, refresh:, batch:)
52
53
  return static_component(component_name, partial:)
53
54
  end
54
55
  if partial
@@ -67,7 +68,8 @@ module SolidObjects
67
68
  locals:,
68
69
  refresh_method: refresh,
69
70
  snapshot:,
70
- refresh_path:
71
+ refresh_path:,
72
+ batch: batch&.to_s
71
73
  )
72
74
  ensure_unique_component!(registration)
73
75
  rendered = ComponentRenderer.new(
@@ -98,6 +100,11 @@ module SolidObjects
98
100
  observable_names.dup
99
101
  end
100
102
 
103
+ # @rbs () -> bool
104
+ def batched_components?
105
+ component_registrations.any?(&:batch)
106
+ end
107
+
101
108
  # @rbs () -> bool
102
109
  def morph_components?
103
110
  component_registrations.any?(&:morph?)
@@ -212,9 +219,9 @@ module SolidObjects
212
219
  "#{registration.component_key.inspect} is already rendered in this solid_object scope"
213
220
  end
214
221
 
215
- # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol) -> void
216
- def validate_static_options!(key:, locals:, refresh:)
217
- return if key.nil? && locals.empty? && refresh.to_s == "replace"
222
+ # @rbs (key: untyped, locals: Hash[untyped, untyped], refresh: String | Symbol, batch: untyped) -> void
223
+ def validate_static_options!(key:, locals:, refresh:, batch:)
224
+ return if key.nil? && locals.empty? && refresh.to_s == "replace" && batch.nil?
218
225
 
219
226
  raise ArgumentError,
220
227
  "key, locals, and refresh require an observable component"
@@ -7,6 +7,7 @@ module SolidObjects
7
7
  # @rbs @reference: Reference
8
8
  # @rbs @component_name: String
9
9
  # @rbs @component_key: String | Integer?
10
+ # @rbs @batch: String?
10
11
  # @rbs @dependencies: Array[String]
11
12
  # @rbs @locals: Hash[String, untyped]
12
13
  # @rbs @refresh_method: String
@@ -18,6 +19,7 @@ module SolidObjects
18
19
  attr_reader :reference,
19
20
  :component_name,
20
21
  :component_key,
22
+ :batch,
21
23
  :dependencies,
22
24
  :locals,
23
25
  :refresh_method,
@@ -26,7 +28,7 @@ module SolidObjects
26
28
  :refresh_path,
27
29
  :token
28
30
 
29
- # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
31
+ # @rbs (reference: Reference, component_name: String, component_key: String | Integer?, dependencies: Array[String], locals: Hash[String, untyped], refresh_method: String, instance_id: Integer, revision: Integer, refresh_path: String, token: String, ?batch: String?) -> void
30
32
  def initialize(
31
33
  reference:,
32
34
  component_name:,
@@ -37,9 +39,11 @@ module SolidObjects
37
39
  instance_id:,
38
40
  revision:,
39
41
  refresh_path:,
40
- token:
42
+ token:,
43
+ batch: nil
41
44
  )
42
45
  @reference = reference
46
+ @batch = batch
43
47
  @component_name = component_name
44
48
  @component_key = component_key
45
49
  @dependencies = dependencies.freeze
@@ -52,7 +56,7 @@ module SolidObjects
52
56
  end
53
57
 
54
58
  class << self
55
- # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
59
+ # @rbs (reference: Reference, component_name: String, component_key: untyped, dependencies: Array[String], locals: Hash[untyped, untyped], refresh_method: String | Symbol, snapshot: ActorSnapshot, refresh_path: String, ?batch: String?) -> ComponentRegistration
56
60
  def issue(
57
61
  reference:,
58
62
  component_name:,
@@ -61,7 +65,8 @@ module SolidObjects
61
65
  locals:,
62
66
  refresh_method:,
63
67
  snapshot:,
64
- refresh_path:
68
+ refresh_path:,
69
+ batch: nil
65
70
  )
66
71
  token = ComponentToken.generate(
67
72
  reference:,
@@ -70,6 +75,7 @@ module SolidObjects
70
75
  dependencies:,
71
76
  locals:,
72
77
  refresh_method:,
78
+ batch:,
73
79
  instance_id: snapshot.instance_id,
74
80
  revision: snapshot.revision,
75
81
  refresh_path:
@@ -99,6 +105,7 @@ module SolidObjects
99
105
  reference:,
100
106
  component_name: payload.fetch("component_name"),
101
107
  component_key: payload["component_key"],
108
+ batch: payload["batch"],
102
109
  dependencies:,
103
110
  locals: payload.fetch("locals"),
104
111
  refresh_method: payload.fetch("refresh_method"),
@@ -149,6 +156,18 @@ module SolidObjects
149
156
  locals.merge("component_key" => component_key).freeze
150
157
  end
151
158
 
159
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> String
160
+ def batch_refresh_url(registrations, instance_id, revision)
161
+ query = URI.encode_www_form(
162
+ [
163
+ [ "instance_id", instance_id ],
164
+ [ "revision", revision ],
165
+ *registrations.map { |registration| [ "tokens[]", registration.token ] }
166
+ ]
167
+ )
168
+ "#{refresh_path}/batch?#{query}"
169
+ end
170
+
152
171
  # @rbs (Integer, Integer) -> String
153
172
  def refresh_url(instance_id, revision)
154
173
  query = URI.encode_www_form(
@@ -48,16 +48,17 @@ module SolidObjects
48
48
  observable_name = invalidation.fetch("observable_name")
49
49
  instance_id = invalidation.fetch("instance_id")
50
50
  revision = invalidation.fetch("revision")
51
- registrations.filter_map do |registration|
52
- next unless registration.dependencies.include?(observable_name)
53
- next unless newer_revision?(
54
- registration.dom_id,
55
- instance_id,
56
- revision
57
- )
58
-
59
- refresh(registration, instance_id, revision)
51
+ changed = registrations.select do |registration|
52
+ registration.dependencies.include?(observable_name) &&
53
+ newer_revision?(registration.dom_id, instance_id, revision)
60
54
  end
55
+ batched, individual = changed.partition(&:batch)
56
+ streams = individual.map { |registration| refresh(registration, instance_id, revision) }
57
+ batched.group_by(&:batch).each_value do |group|
58
+ group.each { |registration| record_revision(registration, instance_id, revision) }
59
+ streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
60
+ end
61
+ streams
61
62
  end
62
63
 
63
64
  # @rbs (ActorSnapshot) -> Array[String]
@@ -90,9 +91,14 @@ module SolidObjects
90
91
 
91
92
  attr_reader :registrations, :revisions
92
93
 
94
+ # @rbs (ComponentRegistration, Integer, Integer) -> void
95
+ def record_revision(registration, instance_id, revision)
96
+ revisions[registration.dom_id] = [ instance_id, revision ]
97
+ end
98
+
93
99
  # @rbs (ComponentRegistration, Integer, Integer) -> String
94
100
  def refresh(registration, instance_id, revision)
95
- revisions[registration.dom_id] = [ instance_id, revision ]
101
+ record_revision(registration, instance_id, revision)
96
102
  TurboStreamRenderer.component_refresh(
97
103
  registration,
98
104
  instance_id,
@@ -9,6 +9,7 @@ module SolidObjects
9
9
  MAXIMUM_DEPENDENCIES = 50
10
10
  MAXIMUM_LOCALS = 50
11
11
  MAXIMUM_COMPONENT_KEY_BYTES = 512
12
+ MAXIMUM_BATCH_BYTES = 64
12
13
  REFRESH_METHODS = %w[replace morph].freeze
13
14
  RESERVED_LOCALS = %w[actor authorization_context component_key].freeze
14
15
  RUBY_KEYWORDS = %w[
@@ -19,7 +20,7 @@ module SolidObjects
19
20
 
20
21
  module_function
21
22
 
22
- # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol) -> String
23
+ # @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, ?component_key: untyped, ?locals: Hash[untyped, untyped], ?refresh_method: String | Symbol, ?batch: untyped) -> String
23
24
  def generate(
24
25
  reference:,
25
26
  component_name:,
@@ -29,13 +30,15 @@ module SolidObjects
29
30
  refresh_path:,
30
31
  component_key: nil,
31
32
  locals: {},
32
- refresh_method: "replace"
33
+ refresh_method: "replace",
34
+ batch: nil
33
35
  )
34
36
  payload = {
35
37
  "actor_type" => reference.actor_type,
36
38
  "actor_id" => reference.actor_id,
37
39
  "component_name" => component_name,
38
40
  "component_key" => Serialization.dump(component_key),
41
+ "batch" => batch&.to_s,
39
42
  "dependencies" => dependencies,
40
43
  "locals" => Serialization.dump(locals),
41
44
  "refresh_method" => refresh_method.to_s,
@@ -80,6 +83,7 @@ module SolidObjects
80
83
 
81
84
  validate_component_name!(payload.fetch("component_name"))
82
85
  validate_component_key!(payload["component_key"])
86
+ validate_batch!(payload["batch"])
83
87
  validate_dependencies!(payload.fetch("dependencies"))
84
88
  validate_locals!(payload.fetch("locals"))
85
89
  validate_refresh_method!(payload.fetch("refresh_method"))
@@ -97,6 +101,7 @@ module SolidObjects
97
101
  return unless payload.is_a?(Hash)
98
102
 
99
103
  payload["component_key"] = nil unless payload.key?("component_key")
104
+ payload["batch"] = nil unless payload.key?("batch")
100
105
  payload["locals"] = {} unless payload.key?("locals")
101
106
  payload["refresh_method"] = "replace" unless payload.key?("refresh_method")
102
107
  end
@@ -124,6 +129,18 @@ module SolidObjects
124
129
  end
125
130
  private_class_method :validate_component_key!
126
131
 
132
+ # @rbs (untyped) -> void
133
+ def validate_batch!(batch)
134
+ return if batch.nil?
135
+ return if batch.is_a?(String) &&
136
+ batch.bytesize.positive? &&
137
+ batch.bytesize <= MAXIMUM_BATCH_BYTES &&
138
+ batch.match?(/\A[a-zA-Z0-9_]+\z/)
139
+
140
+ raise InvalidComponentToken, "invalid actor component batch"
141
+ end
142
+ private_class_method :validate_batch!
143
+
127
144
  # @rbs (Array[untyped]) -> void
128
145
  def validate_dependencies!(dependencies)
129
146
  valid = dependencies.any? &&
@@ -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,31 @@ 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 (Array[ComponentRegistration], Integer, Integer) -> String
58
+ def batch_refresh(registrations, instance_id, revision)
59
+ first = registrations.first
60
+ scope = DomIdentity.scope(first.reference)
61
+ batch = ERB::Util.html_escape(first.batch)
62
+ source = ERB::Util.html_escape(
63
+ first.batch_refresh_url(registrations, instance_id, revision)
64
+ )
65
+ targets = ERB::Util.html_escape(registrations.map(&:dom_id).join(" "))
66
+ %(<turbo-stream action="append" target="#{scope}"><template><solid-objects-batch-refresh data-batch="#{batch}" data-revision="#{instance_id}:#{revision}" data-targets="#{targets}" data-source="#{source}"></solid-objects-batch-refresh></template></turbo-stream>)
67
+ end
68
+
69
+ # @rbs (Hash[String, untyped]) -> String
70
+ def state_payload(payload)
71
+ reference = Reference.new(
72
+ actor_type: payload.fetch("actor_type"),
73
+ actor_id: payload.fetch("actor_id")
74
+ )
75
+ scope = DomIdentity.scope(reference)
76
+ name = ERB::Util.html_escape(payload.fetch("name"))
77
+ revision = "#{payload.fetch("instance_id")}:#{payload.fetch("revision")}"
78
+ body = ERB::Util.html_escape(JSON.generate(payload.fetch("payload")))
79
+ %(<turbo-stream action="append" target="#{scope}"><template><solid-objects-payload data-name="#{name}" data-revision="#{revision}">#{body}</solid-objects-payload></template></turbo-stream>)
80
+ end
81
+
53
82
  # @rbs (String) -> Hash[String, untyped]?
54
83
  def invalidation(stream)
55
84
  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.7.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,11 +2,22 @@
2
2
 
3
3
  module SolidObjects
4
4
  class ComponentsController < ActionController::Base
5
+ BATCH_LIMIT: ::Integer
6
+
5
7
  # @rbs () -> void
6
8
  def show: () -> void
7
9
 
10
+ # @rbs () -> void
11
+ def batch: () -> void
12
+
8
13
  private
9
14
 
15
+ # @rbs (Hash[Symbol, untyped]) -> void
16
+ def refresh_batch: (Hash[Symbol, untyped]) -> void
17
+
18
+ # @rbs (Array[ComponentRegistration]) -> void
19
+ def validate_single_batch!: (Array[ComponentRegistration]) -> void
20
+
10
21
  # @rbs (Hash[Symbol, untyped]) -> void
11
22
  def refresh: (Hash[Symbol, untyped]) -> void
12
23
 
@@ -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