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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +62 -20
- data/app/controllers/solid_objects/components_controller.rb +4 -4
- data/app/models/solid_objects/broadcast.rb +8 -0
- data/app/models/solid_objects/message.rb +1 -1
- data/app/views/solid_objects/dead_letters/index.html.erb +1 -1
- data/app/views/solid_objects/instances/show.html.erb +1 -1
- data/benchmark/support.rb +10 -10
- data/db/migrate/20260813000000_rename_message_dispatch_columns.rb +53 -0
- data/docs/architecture.md +14 -11
- data/docs/authorization.md +2 -2
- data/docs/database-schema.md +6 -6
- data/docs/development.md +13 -1
- data/docs/migrating-existing-state.md +1 -3
- data/docs/realtime.md +25 -3
- data/docs/roadmap.md +9 -5
- data/docs/security.md +8 -0
- data/examples/application/app/actors/shopping_cart_actor.rb +3 -3
- data/lib/solid_objects/actor.rb +49 -37
- data/lib/solid_objects/actor_channel.rb +10 -7
- data/lib/solid_objects/actor_definition.rb +24 -9
- data/lib/solid_objects/actor_view.rb +7 -2
- data/lib/solid_objects/application_write_guard.rb +1 -1
- data/lib/solid_objects/client.rb +52 -52
- data/lib/solid_objects/component_registration.rb +2 -2
- data/lib/solid_objects/component_renderer.rb +1 -1
- data/lib/solid_objects/component_subscriptions.rb +21 -21
- data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
- data/lib/solid_objects/dead_letter_manager.rb +4 -4
- data/lib/solid_objects/doctor.rb +8 -8
- data/lib/solid_objects/effect_executor.rb +30 -22
- data/lib/solid_objects/errors.rb +30 -27
- data/lib/solid_objects/executor.rb +47 -39
- data/lib/solid_objects/lease.rb +3 -3
- data/lib/solid_objects/mailbox.rb +34 -27
- data/lib/solid_objects/operation_dispatcher.rb +51 -0
- data/lib/solid_objects/payload_broadcast.rb +1 -1
- data/lib/solid_objects/reference.rb +60 -29
- data/lib/solid_objects/reminder_scheduler.rb +34 -20
- data/lib/solid_objects/sync_diagnostics.rb +6 -6
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +16 -16
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor.rbs +19 -19
- data/sig/generated/lib/solid_objects/actor_definition.rbs +13 -6
- data/sig/generated/lib/solid_objects/client.rbs +8 -8
- data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
- data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
- data/sig/generated/lib/solid_objects/errors.rbs +21 -18
- data/sig/generated/lib/solid_objects/executor.rbs +10 -10
- data/sig/generated/lib/solid_objects/lease.rbs +2 -2
- data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
- data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
- data/sig/generated/lib/solid_objects/reference.rbs +7 -4
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- 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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
27
|
-
components, signed component locals, and authorized
|
|
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
|
|
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"
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Actor
|
|
5
|
-
EffectIntent = Data.define(:name, :arguments, :
|
|
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, :
|
|
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
|
|
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[
|
|
167
|
-
raise
|
|
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
|
-
|
|
181
|
-
|
|
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 (
|
|
200
|
-
def schedule(
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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,
|
|
223
|
-
def send_to(reference,
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
230
|
-
handler = self.class.definition.messages[
|
|
231
|
-
self.class.definition.queries[
|
|
232
|
-
raise UnknownMessage, "unknown
|
|
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(
|
|
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
|
|
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
|
-
|
|
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!(
|
|
337
|
-
return unless
|
|
338
|
-
return if self.class.definition.messages.key?(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
78
|
+
add_handler(collection: queries, name:, block:)
|
|
76
79
|
end
|
|
77
80
|
|
|
78
|
-
# @rbs (Symbol | String, Proc
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
operation: name.to_s,
|
|
158
163
|
arguments: {},
|
|
159
164
|
authorization_context:
|
|
160
165
|
)
|
data/lib/solid_objects/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
19
|
-
raise UnknownMessage, "unknown
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
arguments
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
query = actor_class.definition.queries.key?(
|
|
43
|
-
actor_message = actor_class.definition.messages.key?(
|
|
44
|
-
raise UnknownMessage, "unknown
|
|
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
|
-
|
|
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,
|
|
53
|
+
reject_sync_inside_transaction!(reference, operation)
|
|
54
54
|
SyncDeadline.with(timeout:) do
|
|
55
55
|
message_reference = enqueue_sync(
|
|
56
|
-
reference
|
|
57
|
-
|
|
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.
|
|
79
|
-
actor_message = actor_class.definition.messages.key?(message.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
148
|
-
arguments
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
200
|
+
operation: operation.to_s,
|
|
201
201
|
arguments:,
|
|
202
202
|
authorization_context:
|
|
203
203
|
)
|