solid_objects 0.11.0 → 0.12.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +62 -20
  4. data/app/controllers/solid_objects/components_controller.rb +4 -4
  5. data/app/models/solid_objects/broadcast.rb +8 -0
  6. data/app/models/solid_objects/message.rb +1 -1
  7. data/app/views/solid_objects/dead_letters/index.html.erb +1 -1
  8. data/app/views/solid_objects/instances/show.html.erb +1 -1
  9. data/benchmark/support.rb +10 -10
  10. data/db/migrate/20260813000000_rename_message_dispatch_columns.rb +53 -0
  11. data/docs/architecture.md +14 -11
  12. data/docs/authorization.md +2 -2
  13. data/docs/database-schema.md +6 -6
  14. data/docs/development.md +13 -1
  15. data/docs/migrating-existing-state.md +1 -3
  16. data/docs/realtime.md +25 -3
  17. data/docs/roadmap.md +9 -5
  18. data/docs/security.md +8 -0
  19. data/examples/application/app/actors/shopping_cart_actor.rb +3 -3
  20. data/lib/solid_objects/actor.rb +49 -37
  21. data/lib/solid_objects/actor_channel.rb +10 -7
  22. data/lib/solid_objects/actor_definition.rb +24 -9
  23. data/lib/solid_objects/actor_view.rb +7 -2
  24. data/lib/solid_objects/application_write_guard.rb +1 -1
  25. data/lib/solid_objects/client.rb +52 -52
  26. data/lib/solid_objects/component_registration.rb +2 -2
  27. data/lib/solid_objects/component_renderer.rb +1 -1
  28. data/lib/solid_objects/component_subscriptions.rb +21 -21
  29. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  30. data/lib/solid_objects/dead_letter_manager.rb +4 -4
  31. data/lib/solid_objects/doctor.rb +8 -8
  32. data/lib/solid_objects/effect_executor.rb +30 -22
  33. data/lib/solid_objects/errors.rb +30 -27
  34. data/lib/solid_objects/executor.rb +47 -39
  35. data/lib/solid_objects/lease.rb +3 -3
  36. data/lib/solid_objects/mailbox.rb +34 -27
  37. data/lib/solid_objects/operation_dispatcher.rb +51 -0
  38. data/lib/solid_objects/payload_broadcast.rb +1 -1
  39. data/lib/solid_objects/reference.rb +60 -29
  40. data/lib/solid_objects/reminder_scheduler.rb +34 -20
  41. data/lib/solid_objects/sync_diagnostics.rb +6 -6
  42. data/lib/solid_objects/test_helper.rb +16 -0
  43. data/lib/solid_objects/turbo_stream_renderer.rb +16 -16
  44. data/lib/solid_objects/version.rb +1 -1
  45. data/lib/solid_objects.rb +1 -0
  46. data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
  47. data/sig/generated/lib/solid_objects/actor.rbs +19 -19
  48. data/sig/generated/lib/solid_objects/actor_definition.rbs +13 -6
  49. data/sig/generated/lib/solid_objects/client.rbs +8 -8
  50. data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
  51. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
  52. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  53. data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
  54. data/sig/generated/lib/solid_objects/errors.rbs +21 -18
  55. data/sig/generated/lib/solid_objects/executor.rbs +10 -10
  56. data/sig/generated/lib/solid_objects/lease.rbs +2 -2
  57. data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
  58. data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
  59. data/sig/generated/lib/solid_objects/reference.rbs +7 -4
  60. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
  61. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
  62. data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
  63. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
  64. data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
  65. metadata +5 -2
data/docs/realtime.md CHANGED
@@ -65,6 +65,27 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
65
65
  invalidation correct and prevents a partial from silently depending on state
66
66
  that cannot wake it.
67
67
 
68
+ Observable values are shared projections. By default, each changed value is
69
+ stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
70
+ every subscriber that passes `authorize_subscription`. Authorization to the
71
+ actor stream is not a per-viewer projection.
72
+
73
+ For a component dependency whose value must never enter the durable outbox or
74
+ Action Cable frame, declare it invalidation-only:
75
+
76
+ ```ruby
77
+ observable :player_one, broadcast: :invalidation do
78
+ player_in_seat(1)
79
+ end
80
+ ```
81
+
82
+ The runtime still compares the value around each successful turn and uses a
83
+ change to refresh components, but stores `{}` and renders no scalar Turbo
84
+ replacement. An invalidation-only observable therefore cannot be used as a
85
+ scalar value such as `actor.player_one`. The component endpoint reads the
86
+ latest committed value and authorizes it again; subscriber-specific state
87
+ belongs in `broadcast_payload`.
88
+
68
89
  ```erb
69
90
  <ul>
70
91
  <% actor.recent_messages.each do |message| %>
@@ -416,7 +437,8 @@ component key, locals, or DOM ID.
416
437
  ## Broadcast durability
417
438
 
418
439
  The actor's fenced commit compares observables before and after the turn and
419
- inserts one broadcast row per changed value. The actor state, monotonic
440
+ inserts one broadcast row per changed observable. Value-broadcast observables
441
+ store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
420
442
  `state_revision`, message completion, and broadcast rows commit atomically. A
421
443
  rolled-back or fenced-out turn therefore cannot invalidate a component.
422
444
 
@@ -456,8 +478,8 @@ response revision fencing.
456
478
  ## Cost model
457
479
 
458
480
  The durable row cost is unchanged: one broadcast row per changed observable,
459
- containing its JSON value and the message/instance references needed to derive
460
- invalidation metadata. No rendered document is stored. Each affected component
481
+ containing either its JSON value or an empty invalidation marker plus the
482
+ message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
461
483
  adds one authorized GET and one partial render per non-coalesced state
462
484
  revision. A repeated keyed component adds one GET and render per key. Signed
463
485
  locals increase page and Cable subscription bytes but do not create durable
data/docs/roadmap.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  - Rails engine, install generator, migration, and CLI
6
6
  - Explicit actor registry, references, JSON state, and state migrations
7
- - Direct synchronous RPC, explicit `sync`, and durable `async`
7
+ - Fluent direct synchronous RPC, configured `sync`, and durable `async`
8
8
  - Durable message history plus ready/claimed membership tables
9
9
  - Concurrent sequence allocation and actor creation
10
10
  - Activation leases, renewal, unique activation tokens, generations, and
@@ -12,7 +12,8 @@
12
12
  - Bounded activation passes, idle cache, hot-actor yield, and process records
13
13
  - At-least-once retries, terminal domain rejection, strict poison ordering,
14
14
  dead letters, and tail retry
15
- - Transactional effects with success/failure actor messages
15
+ - Transactional effects with success/failure actor messages carrying the
16
+ originally staged arguments for callback correlation
16
17
  - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
17
18
  portable status rows with polling indexes and database check constraints on
18
19
  status, which works on all three adapters; a future version may add narrow
@@ -23,8 +24,10 @@
23
24
  listed here while broken in that worker: the scheduler reached a constant the
24
25
  caller path happened to load, so reminders never fired in production and
25
26
  every in-process test still passed
26
- - Durable observable invalidations, scalar Turbo replacement, keyed ERB
27
- components, signed component locals, and authorized replace or morph refresh
27
+ - Durable value or invalidation-only observable broadcasts, scalar Turbo
28
+ replacement, keyed ERB components, signed component locals, and authorized
29
+ replace or morph refresh. Invalidation-only observables retain component
30
+ change detection while storing and broadcasting no projected value
28
31
  - Batched component refreshes: components sharing a signed `batch:` collapse to
29
32
  one browser request per revision, served as HTML frames in a JSON envelope
30
33
  - Personalized state payload broadcasts computed per subscriber under that
@@ -45,7 +48,8 @@
45
48
  - Bounded message/process pruning, actor-type opt-in instance expiration,
46
49
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
47
50
  helper that clears every actor-owned table itself rather than relying on the
48
- database cascade, which a host application may not enforce
51
+ database cascade, which a host application may not enforce, and runs due
52
+ reminders against an explicit test time without moving the database clock
49
53
  - Supervisor role replacement: a role whose thread dies is restarted until
50
54
  shutdown is requested, and dead process records plus expired message and
51
55
  process history are pruned on their own intervals without an application
data/docs/security.md CHANGED
@@ -36,6 +36,14 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
36
36
  authorization. Signed stream tokens are readable by their recipient and prove
37
37
  integrity only.
38
38
 
39
+ Every normal observable value is stored in the broadcast outbox and can reach
40
+ every subscriber that passes `authorize_subscription` for the actor. Never put
41
+ credentials, session identifiers, private cards, hidden library order, or any
42
+ other subscriber-specific state in a value-broadcast observable. Declare a
43
+ component dependency with `broadcast: :invalidation` when only change metadata
44
+ may cross the shared stream, or use `broadcast_payload` for a projection that
45
+ must be computed separately for each authorized connection.
46
+
39
47
  ## Serialization
40
48
 
41
49
  The built-in serializer accepts JSON-compatible data, normalizes keys to
@@ -64,14 +64,14 @@ class ShoppingCartActor < SolidObjects::Actor
64
64
  )
65
65
  end
66
66
 
67
- def payment_succeeded(effect_id:, result:)
67
+ def payment_succeeded(effect_id:, arguments:, result:)
68
68
  return unless checkout_status == "pending"
69
- return unless result.fetch("payment_id") == payment_id
69
+ return unless arguments.fetch("payment_id") == payment_id
70
70
 
71
71
  self.checkout_status = "paid"
72
72
  end
73
73
 
74
- def payment_failed(effect_id:, error:)
74
+ def payment_failed(effect_id:, arguments:, error:)
75
75
  return unless checkout_status == "pending"
76
76
 
77
77
  self.checkout_status = "payment_failed"
@@ -2,10 +2,10 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Actor
5
- EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
5
+ EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
6
6
  CommitActionIntent = Data.define(:name, :arguments)
7
7
  ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
8
- OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
8
+ OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
9
9
 
10
10
  class << self
11
11
  # @rbs (Class) -> void
@@ -52,9 +52,9 @@ module SolidObjects
52
52
  definition.add_query(name, block)
53
53
  end
54
54
 
55
- # @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
56
- def observable(name, &block)
57
- definition.add_observable(name, block)
55
+ # @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
56
+ def observable(name, broadcast: :value, &block)
57
+ definition.add_observable(name, block, broadcast:)
58
58
  end
59
59
 
60
60
  # @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
@@ -72,7 +72,7 @@ module SolidObjects
72
72
 
73
73
  # @rbs (from: Integer, to: Integer) { (Hash[String, untyped]) -> Hash[String, untyped] } -> ActorDefinition::StateMigration
74
74
  def migrate_state(from:, to:, &block)
75
- definition.add_state_migration(from, to, block)
75
+ definition.add_state_migration(from:, to:, block:)
76
76
  end
77
77
 
78
78
  # @rbs () { () -> untyped } -> Proc
@@ -163,8 +163,9 @@ module SolidObjects
163
163
  # @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
164
164
  def reject(code, message, details: {})
165
165
  rejection_code = code.to_s
166
- unless rejection_code.match?(/\A[a-z][a-z0-9_]*\z/)
167
- raise ArgumentError, "rejection code must contain lowercase letters, digits, and underscores"
166
+ unless rejection_code.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
167
+ raise InvalidRejectionCode,
168
+ "invalid rejection code #{rejection_code.inspect}; expected a letter or underscore followed by letters, digits, or underscores"
168
169
  end
169
170
 
170
171
  raise Rejected.new(code: rejection_code, message:, details:)
@@ -177,8 +178,8 @@ module SolidObjects
177
178
  EffectIntent.new(
178
179
  name: name.to_s,
179
180
  arguments: Serialization.dump(arguments),
180
- success_message_name: on_success&.to_s,
181
- failure_message_name: on_failure&.to_s
181
+ success_operation: on_success&.to_s,
182
+ failure_operation: on_failure&.to_s
182
183
  ).tap do |intent|
183
184
  effect_intents << intent
184
185
  end
@@ -196,8 +197,8 @@ module SolidObjects
196
197
  nil
197
198
  end
198
199
 
199
- # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
200
- def schedule(name, at:, every: nil, missed: :latest, arguments: {})
200
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201
+ def schedule(at:, every: nil, missed: :latest)
201
202
  interval_seconds = every&.to_f
202
203
  if interval_seconds && !interval_seconds.positive?
203
204
  raise ArgumentError, "reminder interval must be positive"
@@ -207,31 +208,42 @@ module SolidObjects
207
208
  raise ArgumentError, "missed reminder policy must be all or latest"
208
209
  end
209
210
 
210
- ReminderIntent.new(
211
- name: name.to_s,
212
- at:,
213
- arguments: Serialization.dump(arguments),
214
- interval_seconds:,
215
- missed_policy:
216
- ).tap do |intent|
217
- reminder_intents << intent
211
+ OperationDispatcher.new(
212
+ actor_type: self.class.actor_type,
213
+ handlers: self.class.definition.messages
214
+ ) do |operation, arguments|
215
+ ReminderIntent.new(
216
+ name: operation.to_s,
217
+ at:,
218
+ arguments: Serialization.dump(arguments),
219
+ interval_seconds:,
220
+ missed_policy:
221
+ ).tap do |intent|
222
+ reminder_intents << intent
223
+ end
224
+ nil
218
225
  end
219
- nil
220
226
  end
221
227
 
222
- # @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> nil
223
- def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
224
- stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
225
- nil
228
+ # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
229
+ def send_to(reference, available_at: nil, idempotency_key: nil)
230
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
231
+ OperationDispatcher.new(
232
+ actor_type: reference.actor_type,
233
+ handlers: actor_class.definition.messages
234
+ ) do |operation, arguments|
235
+ stage_outbound_message(reference:, operation:, arguments:, available_at:, idempotency_key:)
236
+ nil
237
+ end
226
238
  end
227
239
 
228
240
  # @rbs (Symbol | String, Hash[String, untyped]) -> untyped
229
- def invoke(message_name, arguments)
230
- handler = self.class.definition.messages[message_name.to_sym] ||
231
- self.class.definition.queries[message_name.to_sym]
232
- raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
241
+ def invoke(operation, arguments)
242
+ handler = self.class.definition.messages[operation.to_sym] ||
243
+ self.class.definition.queries[operation.to_sym]
244
+ raise UnknownMessage, "unknown operation #{operation.inspect} for #{self.class.actor_type}" unless handler
233
245
 
234
- guard_application_writes(message_name.to_s) do
246
+ guard_application_writes(operation.to_s) do
235
247
  instance_exec(**keyword_arguments(arguments), &handler.block)
236
248
  end
237
249
  end
@@ -270,12 +282,12 @@ module SolidObjects
270
282
  end
271
283
  end
272
284
 
273
- # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
274
- def stage_outbound_message(reference, message_name, arguments, available_at: nil, idempotency_key: nil)
285
+ # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
286
+ def stage_outbound_message(reference:, operation:, arguments:, available_at: nil, idempotency_key: nil)
275
287
  OutboundMessageIntent.new(
276
288
  actor_type: reference.actor_type,
277
289
  actor_id: reference.actor_id,
278
- message_name: message_name.to_s,
290
+ operation: operation.to_s,
279
291
  arguments: Serialization.dump(arguments),
280
292
  available_at:,
281
293
  idempotency_key:
@@ -333,11 +345,11 @@ module SolidObjects
333
345
  end
334
346
 
335
347
  # @rbs (Symbol | String?) -> void
336
- def validate_effect_callback!(message_name)
337
- return unless message_name
338
- return if self.class.definition.messages.key?(message_name.to_sym)
348
+ def validate_effect_callback!(operation)
349
+ return unless operation
350
+ return if self.class.definition.messages.key?(operation.to_sym)
339
351
 
340
- raise UnknownMessage, "unknown effect callback message #{message_name.inspect}"
352
+ raise UnknownMessage, "unknown effect callback operation #{operation.inspect}"
341
353
  end
342
354
  end
343
355
  end
@@ -32,9 +32,9 @@ module SolidObjects
32
32
  snapshot = ActorSnapshot.new(reference)
33
33
  scalar_observable_names(snapshot).each do |name|
34
34
  transmit TurboStreamRenderer.observable_value(
35
- reference,
36
- name,
37
- snapshot.observable_value(name)
35
+ reference:,
36
+ name:,
37
+ value: snapshot.observable_value(name)
38
38
  )
39
39
  end
40
40
  refresh_outdated_components(snapshot)
@@ -165,13 +165,13 @@ module SolidObjects
165
165
  def validate_scalar_observables!
166
166
  return unless scalar_observables
167
167
 
168
- observables = SolidObjects
168
+ definition = SolidObjects
169
169
  .registry
170
170
  .fetch(reference.actor_type)
171
171
  .definition
172
- .observables
173
172
  unknown = scalar_observables.find do |name|
174
- !observables.key?(name.to_sym)
173
+ !definition.observables.key?(name.to_sym) ||
174
+ !definition.broadcasts_observable_value?(name)
175
175
  end
176
176
  return unless unknown
177
177
 
@@ -182,7 +182,10 @@ module SolidObjects
182
182
  def scalar_observable_names(snapshot)
183
183
  return scalar_observables if scalar_observables
184
184
 
185
- snapshot.actor_class.definition.observables.keys.map(&:to_s)
185
+ definition = snapshot.actor_class.definition
186
+ definition.observables.keys.filter_map do |name|
187
+ name.to_s if definition.broadcasts_observable_value?(name)
188
+ end
186
189
  end
187
190
  end
188
191
  end
@@ -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 @observable_broadcasts: Hash[Symbol, Symbol]
12
13
  # @rbs @payload_broadcasts: Hash[Symbol, Handler]
13
14
  # @rbs @state_version: Integer
14
15
  # @rbs @state_migrations: Array[StateMigration]
@@ -33,6 +34,7 @@ module SolidObjects
33
34
  @messages = {}
34
35
  @queries = {}
35
36
  @observables = {}
37
+ @observable_broadcasts = {}
36
38
  @payload_broadcasts = {}
37
39
  @state_version = 1
38
40
  @state_migrations = []
@@ -45,6 +47,7 @@ module SolidObjects
45
47
  # @rbs (Symbol | String, default: untyped) -> StateDefinition::Attribute
46
48
  def add_attribute(name, default:)
47
49
  attribute_name = name.to_sym
50
+ OperationDispatcher.validate_operation_name!(attribute_name)
48
51
  if messages.key?(attribute_name) || queries.key?(attribute_name)
49
52
  raise InvalidActor, "#{attribute_name.inspect} is already defined"
50
53
  end
@@ -60,7 +63,7 @@ module SolidObjects
60
63
 
61
64
  # @rbs (Symbol | String, Proc) -> Handler
62
65
  def add_message(name, block)
63
- add_handler(messages, name, block)
66
+ add_handler(collection: messages, name:, block:)
64
67
  end
65
68
 
66
69
  # @rbs (Symbol | String, Proc) -> Handler
@@ -72,20 +75,29 @@ module SolidObjects
72
75
  end
73
76
  end
74
77
 
75
- add_handler(queries, name, block)
78
+ add_handler(collection: queries, name:, block:)
76
79
  end
77
80
 
78
- # @rbs (Symbol | String, Proc?) -> Handler
79
- def add_observable(name, block = nil)
81
+ # @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
82
+ def add_observable(name, block = nil, broadcast:)
80
83
  observable_name = name.to_sym
81
84
  raise InvalidActor, "#{observable_name.inspect} observable is already defined" if observables.key?(observable_name)
85
+ unless %i[value invalidation].include?(broadcast)
86
+ raise InvalidActor, "observable broadcast must be :value or :invalidation"
87
+ end
82
88
 
83
89
  observable_block = block || -> { state.fetch(observable_name) }
84
90
  Handler.new(name: observable_name, block: observable_block).tap do |handler|
85
91
  observables[observable_name] = handler
92
+ observable_broadcasts[observable_name] = broadcast
86
93
  end
87
94
  end
88
95
 
96
+ # @rbs (Symbol | String) -> bool
97
+ def broadcasts_observable_value?(name)
98
+ observable_broadcasts.fetch(name.to_sym) == :value
99
+ end
100
+
89
101
  # @rbs (Symbol | String, Proc) -> Handler
90
102
  def add_payload_broadcast(name, block)
91
103
  payload_name = name.to_sym
@@ -121,8 +133,8 @@ module SolidObjects
121
133
  @state_version = version
122
134
  end
123
135
 
124
- # @rbs (Integer, Integer, Proc) -> StateMigration
125
- def add_state_migration(from, to, block)
136
+ # @rbs (from: Integer, to: Integer, block: Proc) -> StateMigration
137
+ def add_state_migration(from:, to:, block:)
126
138
  unless to == from + 1
127
139
  raise InvalidActor, "state migrations must advance exactly one version"
128
140
  end
@@ -167,6 +179,7 @@ module SolidObjects
167
179
  copy.instance_variable_set(:@messages, messages.dup)
168
180
  copy.instance_variable_set(:@queries, queries.dup)
169
181
  copy.instance_variable_set(:@observables, observables.dup)
182
+ copy.instance_variable_set(:@observable_broadcasts, observable_broadcasts.dup)
170
183
  copy.instance_variable_set(:@payload_broadcasts, payload_broadcasts.dup)
171
184
  copy.instance_variable_set(:@state_version, state_version)
172
185
  copy.instance_variable_set(:@state_migrations, state_migrations.dup)
@@ -179,10 +192,11 @@ module SolidObjects
179
192
 
180
193
  private
181
194
 
182
- attr_reader :attribute_queries, :method_messages
195
+ attr_reader :attribute_queries, :method_messages, :observable_broadcasts
183
196
 
184
197
  # @rbs (Symbol) -> Handler
185
198
  def add_method_message(name)
199
+ OperationDispatcher.validate_operation_name!(name)
186
200
  if messages.key?(name) || queries.key?(name)
187
201
  raise InvalidActor, "#{name.inspect} is already defined"
188
202
  end
@@ -222,9 +236,10 @@ module SolidObjects
222
236
  end
223
237
  end
224
238
 
225
- # @rbs (Hash[Symbol, Handler], Symbol | String, Proc) -> Handler
226
- def add_handler(collection, name, block)
239
+ # @rbs (collection: Hash[Symbol, Handler], name: Symbol | String, block: Proc) -> Handler
240
+ def add_handler(collection:, name:, block:)
227
241
  handler_name = name.to_sym
242
+ OperationDispatcher.validate_operation_name!(handler_name)
228
243
  raise InvalidActor, "#{handler_name.inspect} is already defined" if messages.key?(handler_name) || queries.key?(handler_name)
229
244
 
230
245
  Handler.new(name: handler_name, block:).tap { |handler| collection[handler_name] = handler }
@@ -24,8 +24,13 @@ module SolidObjects
24
24
  # @rbs (Symbol | String) -> untyped
25
25
  def value(name)
26
26
  observable_name = name.to_sym
27
- handler = snapshot.actor_class.definition.observables[observable_name]
27
+ definition = snapshot.actor_class.definition
28
+ handler = definition.observables[observable_name]
28
29
  raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
30
+ unless definition.broadcasts_observable_value?(observable_name)
31
+ raise ArgumentError,
32
+ "invalidation-only observable #{observable_name.inspect} cannot render as a scalar target"
33
+ end
29
34
 
30
35
  authorize_read!(observable_name)
31
36
  value = snapshot.observable_value(observable_name)
@@ -154,7 +159,7 @@ module SolidObjects
154
159
  authorized = SolidObjects.configuration.authorize_query.call(
155
160
  actor_type: reference.actor_type,
156
161
  actor_id: reference.actor_id,
157
- message_name: name.to_s,
162
+ operation: name.to_s,
158
163
  arguments: {},
159
164
  authorization_context:
160
165
  )
@@ -16,7 +16,7 @@ module SolidObjects
16
16
  raise ApplicationWriteForbidden.new(
17
17
  actor_type:,
18
18
  actor_id:,
19
- message_name: operation
19
+ operation:
20
20
  )
21
21
  end
22
22
  end
@@ -12,50 +12,50 @@ module SolidObjects
12
12
  @mailbox = mailbox
13
13
  end
14
14
 
15
- # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference
16
- def async(reference, message_name, arguments, available_at: nil, idempotency_key: nil, authorization_context: nil)
15
+ # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], ?available_at: Time?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference
16
+ def async(reference:, operation:, arguments:, available_at: nil, idempotency_key: nil, authorization_context: nil)
17
17
  actor_class = SolidObjects.registry.fetch(reference.actor_type)
18
- message_symbol = message_name.to_sym
19
- raise UnknownMessage, "unknown message #{message_name.inspect}" unless actor_class.definition.messages.key?(message_symbol)
18
+ operation_symbol = operation.to_sym
19
+ raise UnknownMessage, "unknown operation #{operation.inspect}" unless actor_class.definition.messages.key?(operation_symbol)
20
20
 
21
21
  authorize!(
22
- SolidObjects.configuration.authorize_message,
23
- reference,
24
- message_name,
25
- arguments,
22
+ hook: SolidObjects.configuration.authorize_message,
23
+ reference:,
24
+ operation:,
25
+ arguments:,
26
26
  authorization_context:
27
27
  )
28
28
  mailbox.enqueue(
29
- reference,
30
- message_name,
31
- arguments,
32
- kind: "async",
29
+ reference:,
30
+ operation:,
31
+ arguments:,
32
+ delivery_mode: "async",
33
33
  available_at:,
34
34
  idempotency_key:
35
35
  )
36
36
  end
37
37
 
38
- # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped) -> untyped
39
- def sync(reference, message_name, arguments, timeout:, idempotency_key: nil, authorization_context: nil)
38
+ # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], timeout: Numeric, ?idempotency_key: String?, ?authorization_context: untyped) -> untyped
39
+ def sync(reference:, operation:, arguments:, timeout:, idempotency_key: nil, authorization_context: nil)
40
40
  actor_class = SolidObjects.registry.fetch(reference.actor_type)
41
- message_symbol = message_name.to_sym
42
- query = actor_class.definition.queries.key?(message_symbol)
43
- actor_message = actor_class.definition.messages.key?(message_symbol)
44
- raise UnknownMessage, "unknown message #{message_name.inspect}" unless query || actor_message
41
+ operation_symbol = operation.to_sym
42
+ query = actor_class.definition.queries.key?(operation_symbol)
43
+ actor_message = actor_class.definition.messages.key?(operation_symbol)
44
+ raise UnknownMessage, "unknown operation #{operation.inspect}" unless query || actor_message
45
45
 
46
46
  authorize!(
47
- query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
48
- reference,
49
- message_name,
50
- arguments,
47
+ hook: query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
48
+ reference:,
49
+ operation:,
50
+ arguments:,
51
51
  authorization_context:
52
52
  )
53
- reject_sync_inside_transaction!(reference, message_name)
53
+ reject_sync_inside_transaction!(reference, operation)
54
54
  SyncDeadline.with(timeout:) do
55
55
  message_reference = enqueue_sync(
56
- reference,
57
- message_name,
58
- arguments,
56
+ reference:,
57
+ operation:,
58
+ arguments:,
59
59
  idempotency_key:,
60
60
  timeout:
61
61
  )
@@ -75,19 +75,19 @@ module SolidObjects
75
75
  actor_id: message.actor_id
76
76
  )
77
77
  actor_class = SolidObjects.registry.fetch(reference.actor_type)
78
- query = actor_class.definition.queries.key?(message.message_name.to_sym)
79
- actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
78
+ query = actor_class.definition.queries.key?(message.operation.to_sym)
79
+ actor_message = actor_class.definition.messages.key?(message.operation.to_sym)
80
80
  unless query || actor_message
81
- raise UnknownMessage, "unknown message #{message.message_name.inspect}"
81
+ raise UnknownMessage, "unknown operation #{message.operation.inspect}"
82
82
  end
83
83
  authorize!(
84
- query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
85
- reference,
86
- message.message_name,
87
- message.arguments,
84
+ hook: query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
85
+ reference:,
86
+ operation: message.operation,
87
+ arguments: message.arguments,
88
88
  authorization_context:
89
89
  )
90
- reject_sync_inside_transaction!(reference, message.message_name)
90
+ reject_sync_inside_transaction!(reference, message.operation)
91
91
  SynchronousInvocation.new.call(message_reference, timeout:)
92
92
  end
93
93
  rescue DatabaseDeadlineExceeded
@@ -98,10 +98,10 @@ module SolidObjects
98
98
  def snapshot(reference, authorization_context: nil)
99
99
  SolidObjects.registry.fetch(reference.actor_type)
100
100
  authorize!(
101
- SolidObjects.configuration.authorize_query,
102
- reference,
103
- "__snapshot__",
104
- {},
101
+ hook: SolidObjects.configuration.authorize_query,
102
+ reference:,
103
+ operation: "__snapshot__",
104
+ arguments: {},
105
105
  authorization_context:
106
106
  )
107
107
  StateSnapshot.new(reference)
@@ -140,13 +140,13 @@ module SolidObjects
140
140
 
141
141
  attr_reader :mailbox
142
142
 
143
- # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
144
- def enqueue_sync(reference, message_name, arguments, idempotency_key:, timeout:)
143
+ # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
144
+ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:)
145
145
  mailbox.enqueue(
146
- reference,
147
- message_name,
148
- arguments,
149
- kind: "sync",
146
+ reference:,
147
+ operation:,
148
+ arguments:,
149
+ delivery_mode: "sync",
150
150
  idempotency_key:
151
151
  )
152
152
  rescue DatabaseDeadlineExceeded
@@ -154,13 +154,13 @@ module SolidObjects
154
154
  :"sync.enqueue_timeout",
155
155
  actor_type: reference.actor_type,
156
156
  actor_id: reference.actor_id,
157
- message_name: message_name.to_s
157
+ operation: operation.to_s
158
158
  )
159
159
  raise SyncEnqueueTimeout.new(
160
160
  timeout:,
161
161
  actor_type: reference.actor_type,
162
162
  actor_id: reference.actor_id,
163
- message_name: message_name.to_s
163
+ operation: operation.to_s
164
164
  )
165
165
  end
166
166
 
@@ -176,28 +176,28 @@ module SolidObjects
176
176
  end
177
177
 
178
178
  # @rbs (Reference, Symbol | String) -> void
179
- def reject_sync_inside_transaction!(reference, message_name)
179
+ def reject_sync_inside_transaction!(reference, operation)
180
180
  return unless SolidObjects::Record.connection.transaction_open?
181
181
 
182
182
  SolidObjects.instrument(
183
183
  :"sync.transaction_rejected",
184
184
  actor_type: reference.actor_type,
185
185
  actor_id: reference.actor_id,
186
- message_name: message_name.to_s
186
+ operation: operation.to_s
187
187
  )
188
188
  raise SyncInsideTransaction.new(
189
189
  actor_type: reference.actor_type,
190
190
  actor_id: reference.actor_id,
191
- message_name: message_name.to_s
191
+ operation: operation.to_s
192
192
  )
193
193
  end
194
194
 
195
- # @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
196
- def authorize!(hook, reference, message_name, arguments, authorization_context:)
195
+ # @rbs (hook: Proc, reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], authorization_context: untyped) -> void
196
+ def authorize!(hook:, reference:, operation:, arguments:, authorization_context:)
197
197
  authorized = hook.call(
198
198
  actor_type: reference.actor_type,
199
199
  actor_id: reference.actor_id,
200
- message_name: message_name.to_s,
200
+ operation: operation.to_s,
201
201
  arguments:,
202
202
  authorization_context:
203
203
  )