solid_objects 0.2.1 → 0.4.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +261 -31
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +116 -23
  9. data/docs/authorization.md +53 -3
  10. data/docs/benchmarks.md +6 -2
  11. data/docs/correctness.md +45 -1
  12. data/docs/database-schema.md +18 -5
  13. data/docs/development.md +40 -0
  14. data/docs/fit.md +10 -2
  15. data/docs/migrating-existing-state.md +8 -1
  16. data/docs/operations.md +74 -36
  17. data/docs/realtime.md +106 -12
  18. data/docs/roadmap.md +14 -7
  19. data/docs/security.md +25 -3
  20. data/docs/state-migrations.md +2 -0
  21. data/examples/application/README.md +5 -4
  22. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  23. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  24. data/examples/application/config/initializers/solid_objects.rb +9 -3
  25. data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
  26. data/lib/solid_objects/activation.rb +33 -4
  27. data/lib/solid_objects/actor.rb +58 -6
  28. data/lib/solid_objects/actor_channel.rb +73 -5
  29. data/lib/solid_objects/actor_definition.rb +2 -0
  30. data/lib/solid_objects/actor_snapshot.rb +35 -10
  31. data/lib/solid_objects/actor_view.rb +103 -10
  32. data/lib/solid_objects/application_write_guard.rb +24 -0
  33. data/lib/solid_objects/caller_process.rb +28 -0
  34. data/lib/solid_objects/cli.rb +44 -5
  35. data/lib/solid_objects/client.rb +98 -5
  36. data/lib/solid_objects/commit_action_registry.rb +42 -0
  37. data/lib/solid_objects/component_path_resolver.rb +27 -0
  38. data/lib/solid_objects/component_registration.rb +117 -0
  39. data/lib/solid_objects/component_renderer.rb +82 -0
  40. data/lib/solid_objects/component_subscriptions.rb +109 -0
  41. data/lib/solid_objects/component_token.rb +139 -0
  42. data/lib/solid_objects/component_view.rb +67 -0
  43. data/lib/solid_objects/configuration.rb +39 -1
  44. data/lib/solid_objects/database_adapter.rb +28 -1
  45. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  46. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  47. data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
  48. data/lib/solid_objects/errors.rb +156 -0
  49. data/lib/solid_objects/executor.rb +65 -4
  50. data/lib/solid_objects/instance_pruner.rb +97 -0
  51. data/lib/solid_objects/message_pruner.rb +97 -0
  52. data/lib/solid_objects/message_reference.rb +9 -0
  53. data/lib/solid_objects/process_pruner.rb +49 -0
  54. data/lib/solid_objects/reference.rb +5 -0
  55. data/lib/solid_objects/state_snapshot.rb +41 -0
  56. data/lib/solid_objects/stream_token.rb +32 -13
  57. data/lib/solid_objects/sync_deadline.rb +57 -0
  58. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  59. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  60. data/lib/solid_objects/test_helper.rb +78 -0
  61. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  62. data/lib/solid_objects/version.rb +1 -1
  63. data/lib/solid_objects/worker.rb +1 -1
  64. data/lib/solid_objects.rb +40 -0
  65. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  66. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  67. data/sig/generated/lib/solid_objects/actor.rbs +29 -0
  68. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  69. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  70. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  71. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  72. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  73. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  74. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  75. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  76. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  77. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  78. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  79. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  80. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  81. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  82. data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
  83. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  84. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  85. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  86. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  87. data/sig/generated/lib/solid_objects/errors.rbs +130 -0
  88. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  89. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  90. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  91. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  92. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  93. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  94. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  95. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  96. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  97. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  98. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  99. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  100. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  101. data/sig/generated/lib/solid_objects.rbs +12 -0
  102. metadata +34 -1
@@ -3,6 +3,7 @@
3
3
  module SolidObjects
4
4
  class Actor
5
5
  EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
6
+ CommitActionIntent = Data.define(:name, :arguments)
6
7
  ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
7
8
  OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
8
9
 
@@ -131,6 +132,7 @@ module SolidObjects
131
132
  # @rbs @actor_id: String
132
133
  # @rbs @state: State
133
134
  # @rbs @effect_intents: Array[EffectIntent]
135
+ # @rbs @commit_action_intents: Array[CommitActionIntent]
134
136
  # @rbs @reminder_intents: Array[ReminderIntent]
135
137
  # @rbs @outbound_message_intents: Array[OutboundMessageIntent]
136
138
 
@@ -141,6 +143,7 @@ module SolidObjects
141
143
  @actor_id = actor_id
142
144
  @state = state
143
145
  @effect_intents = []
146
+ @commit_action_intents = []
144
147
  @reminder_intents = []
145
148
  @outbound_message_intents = []
146
149
  end
@@ -175,6 +178,17 @@ module SolidObjects
175
178
  nil
176
179
  end
177
180
 
181
+ # @rbs (Symbol | String, **untyped) -> nil
182
+ def commit_action(name, **arguments)
183
+ CommitActionIntent.new(
184
+ name: name.to_s,
185
+ arguments: Serialization.dump(arguments)
186
+ ).tap do |intent|
187
+ commit_action_intents << intent
188
+ end
189
+ nil
190
+ end
191
+
178
192
  # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
179
193
  def schedule(name, at:, every: nil, missed: :latest, arguments: {})
180
194
  interval_seconds = every&.to_f
@@ -210,24 +224,43 @@ module SolidObjects
210
224
  self.class.definition.queries[message_name.to_sym]
211
225
  raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
212
226
 
213
- instance_exec(**keyword_arguments(arguments), &handler.block)
227
+ guard_application_writes(message_name.to_s) do
228
+ instance_exec(**keyword_arguments(arguments), &handler.block)
229
+ end
214
230
  end
215
231
 
216
232
  # @rbs () -> Hash[String, untyped]
217
233
  def observable_values
218
- self.class.definition.observables.each_with_object({}) do |(name, handler), values|
219
- values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
234
+ guard_application_writes("observables") do
235
+ self.class.definition.observables.each_with_object({}) do |(name, handler), values|
236
+ values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
237
+ end
238
+ end
239
+ end
240
+
241
+ # @rbs (Symbol | String) -> untyped
242
+ def observable_value(name)
243
+ observable_name = name.to_sym
244
+ handler = self.class.definition.observables[observable_name]
245
+ raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
246
+
247
+ guard_application_writes("observable.#{observable_name}") do
248
+ Serialization.dump(instance_exec(&handler.block))
220
249
  end
221
250
  end
222
251
 
223
252
  # @rbs () -> void
224
253
  def activate
225
- self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
254
+ guard_application_writes("on_activate") do
255
+ self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
256
+ end
226
257
  end
227
258
 
228
259
  # @rbs () -> void
229
260
  def deactivate
230
- self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
261
+ guard_application_writes("on_deactivate") do
262
+ self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
263
+ end
231
264
  end
232
265
 
233
266
  # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
@@ -247,6 +280,11 @@ module SolidObjects
247
280
  effect_intents.shift(effect_intents.length)
248
281
  end
249
282
 
283
+ # @rbs () -> Array[CommitActionIntent]
284
+ def drain_commit_action_intents
285
+ commit_action_intents.shift(commit_action_intents.length)
286
+ end
287
+
250
288
  # @rbs () -> Array[ReminderIntent]
251
289
  def drain_reminder_intents
252
290
  reminder_intents.shift(reminder_intents.length)
@@ -260,13 +298,27 @@ module SolidObjects
260
298
  # @rbs () -> void
261
299
  def discard_intents
262
300
  effect_intents.clear
301
+ commit_action_intents.clear
263
302
  reminder_intents.clear
264
303
  outbound_message_intents.clear
265
304
  end
266
305
 
267
306
  private
268
307
 
269
- attr_reader :effect_intents, :reminder_intents, :outbound_message_intents
308
+ attr_reader :effect_intents,
309
+ :commit_action_intents,
310
+ :reminder_intents,
311
+ :outbound_message_intents
312
+
313
+ # @rbs (String) { () -> untyped } -> untyped
314
+ def guard_application_writes(operation, &block)
315
+ ApplicationWriteGuard.call(
316
+ actor_type: self.class.actor_type,
317
+ actor_id:,
318
+ operation:,
319
+ &block
320
+ )
321
+ end
270
322
 
271
323
  # @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
272
324
  def keyword_arguments(arguments)
@@ -17,13 +17,81 @@ module SolidObjects
17
17
  )
18
18
  return reject unless authorized
19
19
 
20
- reference = Reference.new(actor_type:, actor_id:)
21
- stream_from StreamName.for(reference)
22
- ActorSnapshot.new(reference).observable_values.each do |name, value|
23
- transmit TurboStreamRenderer.observable_value(reference, name, value)
20
+ @reference = Reference.new(actor_type:, actor_id:)
21
+ @scalar_observables = identity["observables"]
22
+ validate_scalar_observables!
23
+ @component_subscriptions = ComponentSubscriptions.parse(
24
+ params["components"],
25
+ reference:
26
+ )
27
+ stream_from StreamName.for(reference) do |stream|
28
+ receive_broadcast(stream)
29
+ end
30
+ snapshot = ActorSnapshot.new(reference)
31
+ scalar_observable_names(snapshot).each do |name|
32
+ transmit TurboStreamRenderer.observable_value(
33
+ reference,
34
+ name,
35
+ snapshot.observable_value(name)
36
+ )
24
37
  end
25
- rescue KeyError, InvalidStreamToken, UnknownActorType
38
+ refresh_outdated_components(snapshot)
39
+ rescue KeyError,
40
+ JSON::ParserError,
41
+ InvalidStreamToken,
42
+ InvalidComponentToken,
43
+ UnknownActorType
26
44
  reject
27
45
  end
46
+
47
+ private
48
+
49
+ attr_reader :reference, :component_subscriptions, :scalar_observables
50
+
51
+ # @rbs (String) -> void
52
+ def receive_broadcast(stream)
53
+ invalidation = TurboStreamRenderer.invalidation(stream)
54
+ if !invalidation ||
55
+ scalar_observables.nil? ||
56
+ scalar_observables.include?(invalidation.fetch("observable_name"))
57
+ transmit stream
58
+ end
59
+ return unless invalidation
60
+
61
+ component_subscriptions
62
+ .refreshes_for(invalidation)
63
+ .each { |refresh| transmit refresh }
64
+ end
65
+
66
+ # @rbs (ActorSnapshot) -> void
67
+ def refresh_outdated_components(snapshot)
68
+ component_subscriptions
69
+ .reconnect_refreshes(snapshot)
70
+ .each { |refresh| transmit refresh }
71
+ end
72
+
73
+ # @rbs () -> void
74
+ def validate_scalar_observables!
75
+ return unless scalar_observables
76
+
77
+ observables = SolidObjects
78
+ .registry
79
+ .fetch(reference.actor_type)
80
+ .definition
81
+ .observables
82
+ unknown = scalar_observables.find do |name|
83
+ !observables.key?(name.to_sym)
84
+ end
85
+ return unless unknown
86
+
87
+ raise InvalidStreamToken, "unknown scalar observable #{unknown.inspect}"
88
+ end
89
+
90
+ # @rbs (ActorSnapshot) -> Array[String]
91
+ def scalar_observable_names(snapshot)
92
+ return scalar_observables if scalar_observables
93
+
94
+ snapshot.actor_class.definition.observables.keys.map(&:to_s)
95
+ end
28
96
  end
29
97
  end
@@ -136,6 +136,8 @@ module SolidObjects
136
136
  migrated
137
137
  rescue StateMigrationError
138
138
  raise
139
+ rescue ActiveRecord::ReadOnlyError
140
+ raise
139
141
  rescue => error
140
142
  raise StateMigrationError, "state migration failed: #{error.message}"
141
143
  end
@@ -5,34 +5,59 @@ module SolidObjects
5
5
  # @rbs @reference: Reference
6
6
  # @rbs @actor_class: Class
7
7
  # @rbs @actor: Actor
8
+ # @rbs @instance_id: Integer
9
+ # @rbs @revision: Integer
10
+ # @rbs @observable_values: Hash[String, untyped]?
11
+ # @rbs @observable_value_cache: Hash[String, untyped]
8
12
 
9
- attr_reader :reference, :actor_class, :actor
13
+ attr_reader :reference, :actor_class, :actor, :instance_id, :revision
10
14
 
11
15
  # @rbs (Reference) -> void
12
16
  def initialize(reference)
13
17
  @reference = reference
14
18
  @actor_class = SolidObjects.registry.fetch(reference.actor_type)
19
+ @instance = Instance.find_by(
20
+ actor_type: reference.actor_type,
21
+ actor_id: reference.actor_id
22
+ )
23
+ @instance_id = @instance&.id || 0
24
+ @revision = @instance&.state_revision || 0
15
25
  @actor = build_actor
26
+ @observable_values = nil
27
+ @observable_value_cache = {}
16
28
  end
17
29
 
18
30
  # @rbs () -> Hash[String, untyped]
19
31
  def observable_values
20
- actor.observable_values
32
+ @observable_values ||= Serialization.readonly_copy(actor.observable_values)
33
+ end
34
+
35
+ # @rbs (Symbol | String) -> untyped
36
+ def observable_value(name)
37
+ observable_name = name.to_s
38
+ return @observable_value_cache.fetch(observable_name) if @observable_value_cache.key?(observable_name)
39
+
40
+ @observable_value_cache[observable_name] =
41
+ Serialization.readonly_copy(actor.observable_value(observable_name))
21
42
  end
22
43
 
23
44
  private
24
45
 
46
+ attr_reader :instance
47
+
25
48
  # @rbs () -> Actor
26
49
  def build_actor
27
- instance = Instance.find_by(
28
- actor_type: reference.actor_type,
29
- actor_id: reference.actor_id
30
- )
31
50
  state_version = instance&.state_version || actor_class.state_version
32
- state_data = actor_class.definition.migrate_state(
33
- state_version,
34
- instance&.state || {}
35
- )
51
+ state_data = ApplicationWriteGuard.call(
52
+ actor_type: reference.actor_type,
53
+ actor_id: reference.actor_id,
54
+ operation: "state_migration"
55
+ ) do
56
+ actor_class.definition.migrate_state(
57
+ state_version,
58
+ instance&.state || {}
59
+ )
60
+ end
36
61
  actor_class.new(
37
62
  actor_id: reference.actor_id,
38
63
  state: State.new(actor_class.definition.state_definition, state_data)
@@ -6,6 +6,8 @@ module SolidObjects
6
6
  # @rbs @view_context: untyped
7
7
  # @rbs @authorization_context: untyped
8
8
  # @rbs @snapshot: ActorSnapshot
9
+ # @rbs @component_registrations: Array[ComponentRegistration]
10
+ # @rbs @observable_names: Array[String]
9
11
 
10
12
  attr_reader :reference
11
13
 
@@ -15,6 +17,8 @@ module SolidObjects
15
17
  @view_context = view_context
16
18
  @authorization_context = authorization_context
17
19
  @snapshot = ActorSnapshot.new(reference)
20
+ @component_registrations = []
21
+ @observable_names = []
18
22
  end
19
23
 
20
24
  # @rbs (Symbol | String) -> untyped
@@ -24,7 +28,8 @@ module SolidObjects
24
28
  raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
25
29
 
26
30
  authorize_read!(observable_name)
27
- value = snapshot.observable_values.fetch(observable_name.to_s)
31
+ value = snapshot.observable_value(observable_name)
32
+ observable_names << observable_name.to_s unless observable_names.include?(observable_name.to_s)
28
33
  view_context.content_tag(
29
34
  :span,
30
35
  display_value(value),
@@ -32,21 +37,54 @@ module SolidObjects
32
37
  )
33
38
  end
34
39
 
35
- # @rbs (Symbol | String, ?partial: String?) -> untyped
36
- def component(name, partial: nil)
40
+ # @rbs (Symbol | String, ?observes: Symbol | String | Array[Symbol | String]?, ?partial: String?) -> untyped
41
+ def component(name, observes: nil, partial: nil)
37
42
  component_name = normalized_component_name(name)
38
- authorize_read!(component_name)
39
- rendered = view_context.render(
40
- partial: partial || default_partial(component_name),
41
- locals: { actor: self }
43
+ return static_component(component_name, partial:) unless observes
44
+ if partial
45
+ raise ArgumentError,
46
+ "reactive components resolve partials by actor and component name"
47
+ end
48
+
49
+ dependencies = normalized_dependencies(observes)
50
+ validate_dependencies!(dependencies)
51
+ ensure_unique_component!(component_name)
52
+ refresh_path = component_path_resolver.call(view_context:)
53
+ registration = ComponentRegistration.issue(
54
+ reference:,
55
+ component_name:,
56
+ dependencies:,
57
+ snapshot:,
58
+ refresh_path:
42
59
  )
60
+ rendered = ComponentRenderer.new(
61
+ snapshot:,
62
+ component_name:,
63
+ dependencies:,
64
+ view_context:,
65
+ authorization_context:
66
+ ).call
67
+ component_registrations << registration
43
68
  view_context.content_tag(
44
- :div,
69
+ :"turbo-frame",
45
70
  rendered,
46
- id: DomIdentity.component(reference, component_name)
71
+ id: DomIdentity.component(reference, component_name),
72
+ data: {
73
+ solid_objects_revision: "#{snapshot.instance_id}:#{snapshot.revision}"
74
+ }
47
75
  )
48
76
  end
49
77
 
78
+ # @rbs () -> Array[String]
79
+ def component_tokens
80
+ component_registrations.map(&:token)
81
+ end
82
+
83
+ # @rbs () -> Array[String]
84
+ def scalar_observable_names
85
+ observable_names.dup
86
+ end
87
+
50
88
  # @rbs () -> State
51
89
  def state
52
90
  snapshot.actor.state
@@ -66,7 +104,25 @@ module SolidObjects
66
104
 
67
105
  private
68
106
 
69
- attr_reader :view_context, :authorization_context, :snapshot
107
+ attr_reader :view_context,
108
+ :authorization_context,
109
+ :snapshot,
110
+ :component_registrations,
111
+ :observable_names
112
+
113
+ # @rbs (String, partial: String?) -> untyped
114
+ def static_component(component_name, partial:)
115
+ authorize_read!(component_name)
116
+ rendered = view_context.render(
117
+ partial: partial || default_partial(component_name),
118
+ locals: { actor: self }
119
+ )
120
+ view_context.content_tag(
121
+ :div,
122
+ rendered,
123
+ id: DomIdentity.component(reference, component_name)
124
+ )
125
+ end
70
126
 
71
127
  # @rbs (Symbol | String) -> void
72
128
  def authorize_read!(name)
@@ -106,6 +162,43 @@ module SolidObjects
106
162
  component_name
107
163
  end
108
164
 
165
+ # @rbs (Symbol | String | Array[Symbol | String]) -> Array[String]
166
+ def normalized_dependencies(observes)
167
+ dependencies = Array(observes).map(&:to_s).uniq
168
+ if dependencies.empty?
169
+ raise ArgumentError, "reactive components require at least one observable"
170
+ end
171
+
172
+ dependencies
173
+ end
174
+
175
+ # @rbs (Array[String]) -> void
176
+ def validate_dependencies!(dependencies)
177
+ unknown = dependencies.find do |dependency|
178
+ !snapshot.actor_class.definition.observables.key?(dependency.to_sym)
179
+ end
180
+ return unless unknown
181
+
182
+ raise UnknownComponentDependency,
183
+ "unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
184
+ end
185
+
186
+ # @rbs (String) -> void
187
+ def ensure_unique_component!(component_name)
188
+ return unless component_registrations.any? do |registration|
189
+ registration.component_name == component_name
190
+ end
191
+
192
+ raise ArgumentError,
193
+ "component #{component_name.inspect} is already rendered in this solid_object scope"
194
+ end
195
+
196
+ # @rbs () -> Proc | ComponentPathResolver
197
+ def component_path_resolver
198
+ SolidObjects.configuration.component_path_resolver ||
199
+ ComponentPathResolver.new
200
+ end
201
+
109
202
  # @rbs (String) -> String
110
203
  def default_partial(component_name)
111
204
  actor_name = snapshot.actor_class.name
@@ -0,0 +1,24 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ApplicationWriteGuard
5
+ class << self
6
+ # @rbs (actor_type: String, actor_id: String, operation: String) { () -> untyped } -> untyped
7
+ def call(actor_type:, actor_id:, operation:)
8
+ ActiveRecord::Base.while_preventing_writes { yield }
9
+ rescue ActiveRecord::ReadOnlyError
10
+ SolidObjects.instrument(
11
+ :"actor_code.write_forbidden",
12
+ actor_type:,
13
+ actor_id:,
14
+ operation:
15
+ )
16
+ raise ApplicationWriteForbidden.new(
17
+ actor_type:,
18
+ actor_id:,
19
+ message_name: operation
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -5,12 +5,14 @@ module SolidObjects
5
5
  # @rbs @mutex: Thread::Mutex
6
6
  # @rbs @process_id: Integer?
7
7
  # @rbs @registry: ProcessRegistry?
8
+ # @rbs @shutdown_hook_installed: bool
8
9
 
9
10
  # @rbs () -> void
10
11
  def initialize
11
12
  @mutex = Thread::Mutex.new
12
13
  @process_id = nil
13
14
  @registry = nil
15
+ @shutdown_hook_installed = false
14
16
  end
15
17
 
16
18
  # @rbs () -> ProcessRegistry
@@ -18,11 +20,22 @@ module SolidObjects
18
20
  mutex.synchronize do
19
21
  reset_after_fork
20
22
  register unless reusable_registry?
23
+ install_shutdown_hook
21
24
  registry.heartbeat
22
25
  registry
23
26
  end
24
27
  end
25
28
 
29
+ # @rbs () -> bool
30
+ def stop
31
+ mutex.synchronize do
32
+ return false unless @process_id == ::Process.pid
33
+ return false unless registry&.process_record
34
+
35
+ registry.stop.tap { @registry = nil }
36
+ end
37
+ end
38
+
26
39
  private
27
40
 
28
41
  attr_reader :mutex, :registry
@@ -53,5 +66,20 @@ module SolidObjects
53
66
  )
54
67
  registry
55
68
  end
69
+
70
+ # @rbs () -> void
71
+ def install_shutdown_hook
72
+ return if @shutdown_hook_installed
73
+
74
+ @shutdown_hook_installed = true
75
+ at_exit { stop_after_exit }
76
+ end
77
+
78
+ # @rbs () -> bool
79
+ def stop_after_exit
80
+ stop
81
+ rescue
82
+ false
83
+ end
56
84
  end
57
85
  end
@@ -34,7 +34,7 @@ module SolidObjects
34
34
  # @rbs () -> void
35
35
  def status
36
36
  boot_application
37
- authorize_administration!(:status)
37
+ authorize_administration!(:status, resource: "processes")
38
38
  rows = SolidObjects::Process.order(:kind, :started_at).map do |process_record|
39
39
  {
40
40
  id: process_record.id,
@@ -54,10 +54,49 @@ module SolidObjects
54
54
  # @rbs () -> void
55
55
  def cleanup
56
56
  boot_application
57
- authorize_administration!(:cleanup)
57
+ authorize_administration!(:cleanup, resource: "processes")
58
58
  puts JSON.generate(cleaned_processes: ProcessRegistry.cleanup_dead)
59
59
  end
60
60
 
61
+ desc "prune_messages", "Preview or delete expired terminal message history"
62
+ option :environment, type: :string, aliases: "-e"
63
+ option :execute, type: :boolean, default: false
64
+
65
+ # @rbs () -> void
66
+ def prune_messages
67
+ boot_application
68
+ authorize_administration!(:prune, resource: "messages")
69
+ pruner = MessagePruner.new
70
+ count = options[:execute] ? pruner.prune : pruner.preview
71
+ puts JSON.generate(mode: options[:execute] ? "execute" : "preview", messages: count)
72
+ end
73
+
74
+ desc "prune_instances", "Preview or delete expired idle actor instances"
75
+ option :environment, type: :string, aliases: "-e"
76
+ option :execute, type: :boolean, default: false
77
+
78
+ # @rbs () -> void
79
+ def prune_instances
80
+ boot_application
81
+ authorize_administration!(:prune, resource: "instances")
82
+ pruner = InstancePruner.new
83
+ count = options[:execute] ? pruner.prune : pruner.preview
84
+ puts JSON.generate(mode: options[:execute] ? "execute" : "preview", instances: count)
85
+ end
86
+
87
+ desc "prune_processes", "Preview or delete expired stopped process records"
88
+ option :environment, type: :string, aliases: "-e"
89
+ option :execute, type: :boolean, default: false
90
+
91
+ # @rbs () -> void
92
+ def prune_processes
93
+ boot_application
94
+ authorize_administration!(:prune, resource: "processes")
95
+ pruner = ProcessPruner.new
96
+ count = options[:execute] ? pruner.prune : pruner.preview
97
+ puts JSON.generate(mode: options[:execute] ? "execute" : "preview", processes: count)
98
+ end
99
+
61
100
  desc "dead_letters", "Print dead actor messages"
62
101
  option :environment, type: :string, aliases: "-e"
63
102
 
@@ -102,11 +141,11 @@ module SolidObjects
102
141
  value ? Integer(value) : default
103
142
  end
104
143
 
105
- # @rbs (Symbol) -> void
106
- def authorize_administration!(action)
144
+ # @rbs (Symbol, resource: String) -> void
145
+ def authorize_administration!(action, resource:)
107
146
  authorized = SolidObjects.configuration.authorize_administration.call(
108
147
  action:,
109
- resource: "processes",
148
+ resource:,
110
149
  resource_id: nil,
111
150
  authorization_context: { source: "cli" }
112
151
  )