solid_objects 0.1.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/MIT-LICENSE +19 -0
  4. data/README.md +744 -0
  5. data/Rakefile +40 -0
  6. data/app/controllers/solid_objects/application_controller.rb +23 -0
  7. data/app/controllers/solid_objects/dead_letters_controller.rb +23 -0
  8. data/app/controllers/solid_objects/instances_controller.rb +29 -0
  9. data/app/helpers/solid_objects/actor_helper.rb +25 -0
  10. data/app/models/solid_objects/broadcast.rb +10 -0
  11. data/app/models/solid_objects/claimed_message.rb +14 -0
  12. data/app/models/solid_objects/dead_letter.rb +13 -0
  13. data/app/models/solid_objects/effect.rb +10 -0
  14. data/app/models/solid_objects/instance.rb +93 -0
  15. data/app/models/solid_objects/message.rb +53 -0
  16. data/app/models/solid_objects/process.rb +13 -0
  17. data/app/models/solid_objects/ready_message.rb +10 -0
  18. data/app/models/solid_objects/record.rb +17 -0
  19. data/app/models/solid_objects/reminder.rb +9 -0
  20. data/app/views/solid_objects/dead_letters/index.html.erb +26 -0
  21. data/app/views/solid_objects/instances/index.html.erb +24 -0
  22. data/app/views/solid_objects/instances/show.html.erb +35 -0
  23. data/benchmark/activation_cache.rb +5 -0
  24. data/benchmark/ask_latency.rb +5 -0
  25. data/benchmark/claim.rb +5 -0
  26. data/benchmark/cold_actors.rb +5 -0
  27. data/benchmark/concurrent_actors.rb +5 -0
  28. data/benchmark/enqueue.rb +5 -0
  29. data/benchmark/hot_actor.rb +5 -0
  30. data/benchmark/processing.rb +5 -0
  31. data/benchmark/query_count.rb +5 -0
  32. data/benchmark/support.rb +271 -0
  33. data/config/routes.rb +8 -0
  34. data/db/migrate/20260805000000_create_solid_objects_tables.rb +319 -0
  35. data/docs/adr/0001-postgresql-backend.md +21 -0
  36. data/docs/adr/0002-jsonb-actor-state.md +21 -0
  37. data/docs/adr/0003-mailbox-ordering.md +30 -0
  38. data/docs/adr/0004-activation-leasing.md +21 -0
  39. data/docs/adr/0005-fencing-tokens.md +25 -0
  40. data/docs/adr/0006-at-least-once-delivery.md +24 -0
  41. data/docs/adr/0007-transactional-outbox.md +21 -0
  42. data/docs/adr/0008-actor-communication.md +21 -0
  43. data/docs/adr/0009-realtime-updates.md +21 -0
  44. data/docs/adr/0010-state-versioning.md +29 -0
  45. data/docs/adr/0011-wake-up-strategy.md +34 -0
  46. data/docs/adr/0012-not-active-jobs.md +21 -0
  47. data/docs/adr/0013-database-adapters.md +48 -0
  48. data/docs/architecture.md +615 -0
  49. data/docs/benchmarks.md +26 -0
  50. data/docs/correctness.md +124 -0
  51. data/docs/database-schema.md +111 -0
  52. data/docs/development.md +87 -0
  53. data/docs/implementation-plan.md +518 -0
  54. data/docs/operations.md +123 -0
  55. data/docs/realtime.md +51 -0
  56. data/docs/research/solid_queue.md +545 -0
  57. data/docs/roadmap.md +53 -0
  58. data/docs/security.md +61 -0
  59. data/docs/state-migrations.md +46 -0
  60. data/examples/application/README.md +16 -0
  61. data/examples/application/app/actors/chat_room_actor.rb +34 -0
  62. data/examples/application/app/actors/shopping_cart_actor.rb +79 -0
  63. data/examples/application/app/controllers/cart_controller.rb +54 -0
  64. data/examples/application/app/controllers/chat_rooms_controller.rb +44 -0
  65. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +8 -0
  66. data/examples/application/app/views/actors/shopping_cart_actor/_summary.html.erb +10 -0
  67. data/examples/application/app/views/cart/show.html.erb +13 -0
  68. data/examples/application/app/views/chat_rooms/show.html.erb +8 -0
  69. data/examples/application/config/initializers/solid_objects.rb +23 -0
  70. data/examples/application/config/routes.rb +20 -0
  71. data/exe/solid_objects +9 -0
  72. data/lib/generators/solid_objects/install_generator.rb +21 -0
  73. data/lib/generators/solid_objects/templates/solid_objects.rb +13 -0
  74. data/lib/solid_objects/action_cable_broadcast_adapter.rb +19 -0
  75. data/lib/solid_objects/activation.rb +183 -0
  76. data/lib/solid_objects/activation_manager.rb +102 -0
  77. data/lib/solid_objects/actor.rb +271 -0
  78. data/lib/solid_objects/actor_channel.rb +29 -0
  79. data/lib/solid_objects/actor_definition.rb +212 -0
  80. data/lib/solid_objects/actor_registry.rb +65 -0
  81. data/lib/solid_objects/actor_snapshot.rb +42 -0
  82. data/lib/solid_objects/actor_view.rb +117 -0
  83. data/lib/solid_objects/broadcast_executor.rb +162 -0
  84. data/lib/solid_objects/cli.rb +118 -0
  85. data/lib/solid_objects/client.rb +153 -0
  86. data/lib/solid_objects/configuration.rb +168 -0
  87. data/lib/solid_objects/context.rb +41 -0
  88. data/lib/solid_objects/database_adapter.rb +82 -0
  89. data/lib/solid_objects/database_adapters/mysql.rb +22 -0
  90. data/lib/solid_objects/database_adapters/postgresql.rb +17 -0
  91. data/lib/solid_objects/database_adapters/sqlite.rb +12 -0
  92. data/lib/solid_objects/dead_letter_manager.rb +47 -0
  93. data/lib/solid_objects/dom_identity.rb +38 -0
  94. data/lib/solid_objects/effect_executor.rb +235 -0
  95. data/lib/solid_objects/effect_registry.rb +34 -0
  96. data/lib/solid_objects/engine.rb +33 -0
  97. data/lib/solid_objects/errors.rb +65 -0
  98. data/lib/solid_objects/executor.rb +290 -0
  99. data/lib/solid_objects/instrumentation.rb +10 -0
  100. data/lib/solid_objects/lease.rb +172 -0
  101. data/lib/solid_objects/lease_renewer.rb +70 -0
  102. data/lib/solid_objects/log_subscriber.rb +29 -0
  103. data/lib/solid_objects/mailbox.rb +178 -0
  104. data/lib/solid_objects/message_reference.rb +52 -0
  105. data/lib/solid_objects/process_registry.rb +143 -0
  106. data/lib/solid_objects/reference.rb +96 -0
  107. data/lib/solid_objects/reminder_scheduler.rb +168 -0
  108. data/lib/solid_objects/serialization.rb +99 -0
  109. data/lib/solid_objects/state.rb +111 -0
  110. data/lib/solid_objects/stream_name.rb +29 -0
  111. data/lib/solid_objects/stream_token.rb +59 -0
  112. data/lib/solid_objects/supervisor.rb +87 -0
  113. data/lib/solid_objects/turbo_stream_renderer.rb +35 -0
  114. data/lib/solid_objects/version.rb +5 -0
  115. data/lib/solid_objects/wake_up.rb +28 -0
  116. data/lib/solid_objects/worker.rb +139 -0
  117. data/lib/solid_objects.rb +118 -0
  118. data/sig/generated/controllers/solid_objects/application_controller.rbs +10 -0
  119. data/sig/generated/controllers/solid_objects/dead_letters_controller.rbs +11 -0
  120. data/sig/generated/controllers/solid_objects/instances_controller.rbs +11 -0
  121. data/sig/generated/helpers/solid_objects/actor_helper.rbs +8 -0
  122. data/sig/generated/lib/generators/solid_objects/install_generator.rbs +13 -0
  123. data/sig/generated/lib/solid_objects/action_cable_broadcast_adapter.rbs +8 -0
  124. data/sig/generated/lib/solid_objects/activation.rbs +65 -0
  125. data/sig/generated/lib/solid_objects/activation_manager.rbs +36 -0
  126. data/sig/generated/lib/solid_objects/actor.rbs +183 -0
  127. data/sig/generated/lib/solid_objects/actor_channel.rbs +8 -0
  128. data/sig/generated/lib/solid_objects/actor_definition.rbs +117 -0
  129. data/sig/generated/lib/solid_objects/actor_registry.rbs +36 -0
  130. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +28 -0
  131. data/sig/generated/lib/solid_objects/actor_view.rbs +56 -0
  132. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +55 -0
  133. data/sig/generated/lib/solid_objects/cli.rbs +31 -0
  134. data/sig/generated/lib/solid_objects/client.rbs +35 -0
  135. data/sig/generated/lib/solid_objects/configuration.rbs +147 -0
  136. data/sig/generated/lib/solid_objects/context.rbs +56 -0
  137. data/sig/generated/lib/solid_objects/database_adapter.rbs +42 -0
  138. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +16 -0
  139. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +13 -0
  140. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +10 -0
  141. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +16 -0
  142. data/sig/generated/lib/solid_objects/dom_identity.rbs +20 -0
  143. data/sig/generated/lib/solid_objects/effect_executor.rbs +82 -0
  144. data/sig/generated/lib/solid_objects/effect_registry.rbs +24 -0
  145. data/sig/generated/lib/solid_objects/engine.rbs +7 -0
  146. data/sig/generated/lib/solid_objects/errors.rbs +64 -0
  147. data/sig/generated/lib/solid_objects/executor.rbs +60 -0
  148. data/sig/generated/lib/solid_objects/instrumentation.rbs +8 -0
  149. data/sig/generated/lib/solid_objects/lease.rbs +57 -0
  150. data/sig/generated/lib/solid_objects/lease_renewer.rbs +42 -0
  151. data/sig/generated/lib/solid_objects/log_subscriber.rbs +11 -0
  152. data/sig/generated/lib/solid_objects/mailbox.rbs +46 -0
  153. data/sig/generated/lib/solid_objects/message_reference.rbs +37 -0
  154. data/sig/generated/lib/solid_objects/process_registry.rbs +46 -0
  155. data/sig/generated/lib/solid_objects/reference.rbs +45 -0
  156. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +55 -0
  157. data/sig/generated/lib/solid_objects/serialization.rbs +31 -0
  158. data/sig/generated/lib/solid_objects/state.rbs +72 -0
  159. data/sig/generated/lib/solid_objects/stream_name.rbs +11 -0
  160. data/sig/generated/lib/solid_objects/stream_token.rbs +19 -0
  161. data/sig/generated/lib/solid_objects/supervisor.rbs +38 -0
  162. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +14 -0
  163. data/sig/generated/lib/solid_objects/version.rbs +5 -0
  164. data/sig/generated/lib/solid_objects/wake_up.rbs +24 -0
  165. data/sig/generated/lib/solid_objects/worker.rbs +56 -0
  166. data/sig/generated/lib/solid_objects.rbs +41 -0
  167. data/sig/generated/models/solid_objects/broadcast.rbs +6 -0
  168. data/sig/generated/models/solid_objects/claimed_message.rbs +6 -0
  169. data/sig/generated/models/solid_objects/dead_letter.rbs +6 -0
  170. data/sig/generated/models/solid_objects/effect.rbs +6 -0
  171. data/sig/generated/models/solid_objects/instance.rbs +25 -0
  172. data/sig/generated/models/solid_objects/message.rbs +22 -0
  173. data/sig/generated/models/solid_objects/process.rbs +6 -0
  174. data/sig/generated/models/solid_objects/ready_message.rbs +6 -0
  175. data/sig/generated/models/solid_objects/record.rbs +8 -0
  176. data/sig/generated/models/solid_objects/reminder.rbs +6 -0
  177. data/sig/support/framework.rbs +37 -0
  178. metadata +467 -0
@@ -0,0 +1,102 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ActivationManager
5
+ # @rbs @owner_id: String
6
+ # @rbs @database_adapter: DatabaseAdapter
7
+
8
+ # @rbs (owner_id: String, ?database_adapter: DatabaseAdapter) -> void
9
+ def initialize(owner_id:, database_adapter: SolidObjects.database_adapter)
10
+ @owner_id = owner_id
11
+ @database_adapter = database_adapter
12
+ end
13
+
14
+ # @rbs () -> Activation?
15
+ def claim_next
16
+ lease = database_adapter.transaction do
17
+ now = database_adapter.database_now
18
+ claimed_lease = nil
19
+ candidate_instance_ids(now).each do |instance_id|
20
+ instance = database_adapter.lock_candidates(
21
+ Instance.where(id: instance_id)
22
+ ).first
23
+ next unless instance
24
+ next unless claimable?(instance, now)
25
+
26
+ claimed_lease = Lease.claim(instance:, owner_id:, now:, database_adapter:)
27
+ break if claimed_lease
28
+ end
29
+ claimed_lease
30
+ end
31
+ return unless lease
32
+
33
+ SolidObjects.instrument(
34
+ :"activation.claimed",
35
+ instance_id: lease.instance_id,
36
+ owner_id:,
37
+ generation: lease.generation
38
+ )
39
+ Activation.new(lease:)
40
+ rescue
41
+ lease&.release
42
+ raise
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :owner_id, :database_adapter
48
+
49
+ # @rbs (Time) -> Array[Integer]
50
+ def candidate_instance_ids(now)
51
+ (ready_instance_ids(now) + claimed_instance_ids(now)).uniq
52
+ end
53
+
54
+ # @rbs (Time) -> Array[Integer]
55
+ def ready_instance_ids(now)
56
+ ReadyMessage
57
+ .joins(:instance)
58
+ .where(available_at: ..now)
59
+ .where("#{Instance.table_name}.paused_at IS NULL")
60
+ .where(available_lease_sql, now)
61
+ .group(:instance_id)
62
+ .order(
63
+ Arel.sql("MIN(available_at)"),
64
+ Arel.sql("MIN(#{ReadyMessage.table_name}.id)")
65
+ )
66
+ .limit(SolidObjects.configuration.claim_scan_limit)
67
+ .pluck(:instance_id)
68
+ end
69
+
70
+ # @rbs (Time) -> Array[Integer]
71
+ def claimed_instance_ids(now)
72
+ ClaimedMessage
73
+ .joins(:instance)
74
+ .where("#{Instance.table_name}.paused_at IS NULL")
75
+ .where(available_lease_sql, now)
76
+ .group(:instance_id)
77
+ .order(
78
+ Arel.sql("MIN(claimed_at)"),
79
+ Arel.sql("MIN(#{ClaimedMessage.table_name}.id)")
80
+ )
81
+ .limit(SolidObjects.configuration.claim_scan_limit)
82
+ .pluck(:instance_id)
83
+ end
84
+
85
+ # @rbs (Instance, Time) -> bool
86
+ def claimable?(instance, now)
87
+ return false if instance.paused_at
88
+ return true unless instance.activation_owner_id
89
+ return true unless instance.activation_expires_at
90
+
91
+ instance.activation_expires_at <= now
92
+ end
93
+
94
+ # @rbs () -> String
95
+ def available_lease_sql
96
+ table_name = Instance.table_name
97
+ "#{table_name}.activation_owner_id IS NULL OR " \
98
+ "#{table_name}.activation_expires_at IS NULL OR " \
99
+ "#{table_name}.activation_expires_at <= ?"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,271 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Actor
5
+ EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
6
+ ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
7
+ OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
8
+
9
+ class << self
10
+ # @rbs (Class) -> void
11
+ def inherited(subclass)
12
+ super
13
+ subclass.instance_variable_set(:@definition, definition.duplicate)
14
+ subclass.instance_variable_set(:@generated_attribute_methods, generated_attribute_methods.dup)
15
+ end
16
+
17
+ # @rbs (?String | Symbol) -> String
18
+ def actor_type(value = nil)
19
+ if value
20
+ @actor_type = value.to_s
21
+ SolidObjects.register_actor(@actor_type, self)
22
+ end
23
+
24
+ @actor_type ||= default_actor_type
25
+ end
26
+
27
+ # @rbs (Symbol | String, ?default: untyped) -> StateDefinition::Attribute
28
+ def attribute(name, default: nil)
29
+ attribute_name = name.to_sym
30
+ ensure_attribute_methods_available!(attribute_name)
31
+ attribute = definition.add_attribute(attribute_name, default:)
32
+ @defining_attribute_methods = true
33
+ begin
34
+ define_method(attribute_name) { state.fetch(attribute_name) }
35
+ define_method(:"#{attribute_name}=") { |value| state.write(attribute_name, value) }
36
+ generated_attribute_methods[attribute_name] = true
37
+ generated_attribute_methods[:"#{attribute_name}="] = true
38
+ ensure
39
+ @defining_attribute_methods = false
40
+ end
41
+ attribute
42
+ end
43
+
44
+ # @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
45
+ def message(name, &block)
46
+ definition.add_message(name, block)
47
+ end
48
+
49
+ # @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
50
+ def query(name, &block)
51
+ definition.add_query(name, block)
52
+ end
53
+
54
+ # @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
55
+ def observable(name, &block)
56
+ definition.add_observable(name, block)
57
+ end
58
+
59
+ # @rbs (?Integer) -> Integer
60
+ def state_version(version = nil)
61
+ definition.set_state_version(version) if version
62
+ definition.state_version
63
+ end
64
+
65
+ # @rbs (from: Integer, to: Integer) { (Hash[String, untyped]) -> Hash[String, untyped] } -> ActorDefinition::StateMigration
66
+ def migrate_state(from:, to:, &block)
67
+ definition.add_state_migration(from, to, block)
68
+ end
69
+
70
+ # @rbs () { () -> untyped } -> Proc
71
+ def on_activate(&block)
72
+ definition.activation_hooks << block
73
+ block
74
+ end
75
+
76
+ # @rbs () { () -> untyped } -> Proc
77
+ def on_deactivate(&block)
78
+ definition.deactivation_hooks << block
79
+ block
80
+ end
81
+
82
+ # @rbs (String | Integer) -> Reference
83
+ def ref(actor_id)
84
+ ensure_registered!
85
+ Reference.new(actor_type:, actor_id: actor_id.to_s)
86
+ end
87
+
88
+ # @rbs () -> ActorDefinition
89
+ def definition
90
+ (@definition ||= ActorDefinition.new).synchronize_instance_messages(self)
91
+ end
92
+
93
+ # @rbs () -> Class
94
+ def ensure_registered!
95
+ SolidObjects.register_actor(actor_type, self)
96
+ end
97
+
98
+ private
99
+
100
+ # @rbs (Symbol) -> void
101
+ def method_added(name)
102
+ super
103
+ generated_attribute_methods.delete(name) unless @defining_attribute_methods
104
+ end
105
+
106
+ # @rbs () -> Hash[Symbol, bool]
107
+ def generated_attribute_methods
108
+ @generated_attribute_methods ||= {}
109
+ end
110
+
111
+ # @rbs (Symbol) -> void
112
+ def ensure_attribute_methods_available!(attribute_name)
113
+ conflicting_name = [ attribute_name, :"#{attribute_name}=" ].find do |method_name|
114
+ method_defined?(method_name) ||
115
+ private_method_defined?(method_name) ||
116
+ protected_method_defined?(method_name)
117
+ end
118
+ return unless conflicting_name
119
+
120
+ raise InvalidActor, "attribute #{attribute_name.inspect} conflicts with actor method #{conflicting_name}"
121
+ end
122
+
123
+ # @rbs () -> String
124
+ def default_actor_type
125
+ raise InvalidActor, "anonymous actor classes must declare actor_type" unless name
126
+
127
+ name
128
+ end
129
+ end
130
+
131
+ # @rbs @actor_id: String
132
+ # @rbs @state: State
133
+ # @rbs @effect_intents: Array[EffectIntent]
134
+ # @rbs @reminder_intents: Array[ReminderIntent]
135
+ # @rbs @outbound_message_intents: Array[OutboundMessageIntent]
136
+
137
+ attr_reader :actor_id, :state
138
+
139
+ # @rbs (actor_id: String, state: State) -> void
140
+ def initialize(actor_id:, state:)
141
+ @actor_id = actor_id
142
+ @state = state
143
+ @effect_intents = []
144
+ @reminder_intents = []
145
+ @outbound_message_intents = []
146
+ end
147
+
148
+ # @rbs () -> MessageContext?
149
+ def current_message
150
+ Context.current_message
151
+ end
152
+
153
+ # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> EffectIntent
154
+ def emit(name, on_success: nil, on_failure: nil, **arguments)
155
+ validate_effect_callback!(on_success)
156
+ validate_effect_callback!(on_failure)
157
+ EffectIntent.new(
158
+ name: name.to_s,
159
+ arguments: Serialization.dump(arguments),
160
+ success_message_name: on_success&.to_s,
161
+ failure_message_name: on_failure&.to_s
162
+ ).tap do |intent|
163
+ effect_intents << intent
164
+ end
165
+ end
166
+
167
+ # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> ReminderIntent
168
+ def schedule(name, at:, every: nil, missed: :latest, arguments: {})
169
+ interval_seconds = every&.to_f
170
+ if interval_seconds && !interval_seconds.positive?
171
+ raise ArgumentError, "reminder interval must be positive"
172
+ end
173
+ missed_policy = missed.to_s
174
+ unless %w[all latest].include?(missed_policy)
175
+ raise ArgumentError, "missed reminder policy must be all or latest"
176
+ end
177
+
178
+ ReminderIntent.new(
179
+ name: name.to_s,
180
+ at:,
181
+ arguments: Serialization.dump(arguments),
182
+ interval_seconds:,
183
+ missed_policy:
184
+ ).tap do |intent|
185
+ reminder_intents << intent
186
+ end
187
+ end
188
+
189
+ # @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> OutboundMessageIntent
190
+ def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
191
+ stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
192
+ end
193
+
194
+ # @rbs (Symbol | String, Hash[String, untyped]) -> untyped
195
+ def invoke(message_name, arguments)
196
+ handler = self.class.definition.messages[message_name.to_sym] ||
197
+ self.class.definition.queries[message_name.to_sym]
198
+ raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
199
+
200
+ instance_exec(**keyword_arguments(arguments), &handler.block)
201
+ end
202
+
203
+ # @rbs () -> Hash[String, untyped]
204
+ def observable_values
205
+ self.class.definition.observables.each_with_object({}) do |(name, handler), values|
206
+ values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
207
+ end
208
+ end
209
+
210
+ # @rbs () -> void
211
+ def activate
212
+ self.class.definition.activation_hooks.each { |hook| instance_exec(&hook) }
213
+ end
214
+
215
+ # @rbs () -> void
216
+ def deactivate
217
+ self.class.definition.deactivation_hooks.each { |hook| instance_exec(&hook) }
218
+ end
219
+
220
+ # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
221
+ def stage_outbound_message(reference, message_name, arguments, available_at: nil, idempotency_key: nil)
222
+ OutboundMessageIntent.new(
223
+ actor_type: reference.actor_type,
224
+ actor_id: reference.actor_id,
225
+ message_name: message_name.to_s,
226
+ arguments: Serialization.dump(arguments),
227
+ available_at:,
228
+ idempotency_key:
229
+ ).tap { |intent| outbound_message_intents << intent }
230
+ end
231
+
232
+ # @rbs () -> Array[EffectIntent]
233
+ def drain_effect_intents
234
+ effect_intents.shift(effect_intents.length)
235
+ end
236
+
237
+ # @rbs () -> Array[ReminderIntent]
238
+ def drain_reminder_intents
239
+ reminder_intents.shift(reminder_intents.length)
240
+ end
241
+
242
+ # @rbs () -> Array[OutboundMessageIntent]
243
+ def drain_outbound_message_intents
244
+ outbound_message_intents.shift(outbound_message_intents.length)
245
+ end
246
+
247
+ # @rbs () -> void
248
+ def discard_intents
249
+ effect_intents.clear
250
+ reminder_intents.clear
251
+ outbound_message_intents.clear
252
+ end
253
+
254
+ private
255
+
256
+ attr_reader :effect_intents, :reminder_intents, :outbound_message_intents
257
+
258
+ # @rbs (Hash[String, untyped]) -> Hash[Symbol, untyped]
259
+ def keyword_arguments(arguments)
260
+ arguments.each_with_object({}) { |(key, value), converted| converted[key.to_sym] = value }
261
+ end
262
+
263
+ # @rbs (Symbol | String?) -> void
264
+ def validate_effect_callback!(message_name)
265
+ return unless message_name
266
+ return if self.class.definition.messages.key?(message_name.to_sym)
267
+
268
+ raise UnknownMessage, "unknown effect callback message #{message_name.inspect}"
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "action_cable/channel/base"
4
+
5
+ module SolidObjects
6
+ class ActorChannel < ActionCable::Channel::Base
7
+ # @rbs () -> void
8
+ def subscribed
9
+ identity = StreamToken.verify(params.fetch("token"))
10
+ actor_type = identity.fetch("actor_type")
11
+ actor_id = identity.fetch("actor_id")
12
+ SolidObjects.registry.fetch(actor_type)
13
+ authorized = SolidObjects.configuration.authorize_subscription.call(
14
+ actor_type:,
15
+ actor_id:,
16
+ authorization_context: connection
17
+ )
18
+ return reject unless authorized
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)
24
+ end
25
+ rescue KeyError, InvalidStreamToken, UnknownActorType
26
+ reject
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,212 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ActorDefinition
5
+ Handler = Data.define(:name, :block)
6
+ StateMigration = Data.define(:from, :to, :block)
7
+
8
+ # @rbs @state_definition: StateDefinition
9
+ # @rbs @messages: Hash[Symbol, Handler]
10
+ # @rbs @queries: Hash[Symbol, Handler]
11
+ # @rbs @observables: Hash[Symbol, Handler]
12
+ # @rbs @state_version: Integer
13
+ # @rbs @state_migrations: Array[StateMigration]
14
+ # @rbs @activation_hooks: Array[Proc]
15
+ # @rbs @deactivation_hooks: Array[Proc]
16
+ # @rbs @attribute_queries: Hash[Symbol, bool]
17
+ # @rbs @method_messages: Hash[Symbol, bool]
18
+
19
+ attr_reader :state_definition,
20
+ :messages,
21
+ :queries,
22
+ :observables,
23
+ :state_version,
24
+ :state_migrations,
25
+ :activation_hooks,
26
+ :deactivation_hooks
27
+
28
+ # @rbs () -> void
29
+ def initialize
30
+ @state_definition = StateDefinition.new
31
+ @messages = {}
32
+ @queries = {}
33
+ @observables = {}
34
+ @state_version = 1
35
+ @state_migrations = []
36
+ @activation_hooks = []
37
+ @deactivation_hooks = []
38
+ @attribute_queries = {}
39
+ @method_messages = {}
40
+ end
41
+
42
+ # @rbs (Symbol | String, default: untyped) -> StateDefinition::Attribute
43
+ def add_attribute(name, default:)
44
+ attribute_name = name.to_sym
45
+ if messages.key?(attribute_name) || queries.key?(attribute_name)
46
+ raise InvalidActor, "#{attribute_name.inspect} is already defined"
47
+ end
48
+
49
+ state_definition.attribute(attribute_name, default:).tap do
50
+ queries[attribute_name] = Handler.new(
51
+ name: attribute_name,
52
+ block: -> { state.fetch(attribute_name) }
53
+ )
54
+ attribute_queries[attribute_name] = true
55
+ end
56
+ end
57
+
58
+ # @rbs (Symbol | String, Proc) -> Handler
59
+ def add_message(name, block)
60
+ add_handler(messages, name, block)
61
+ end
62
+
63
+ # @rbs (Symbol | String, Proc) -> Handler
64
+ def add_query(name, block)
65
+ query_name = name.to_sym
66
+ if attribute_queries.delete(query_name)
67
+ return Handler.new(name: query_name, block:).tap do |handler|
68
+ queries[query_name] = handler
69
+ end
70
+ end
71
+
72
+ add_handler(queries, name, block)
73
+ end
74
+
75
+ # @rbs (Symbol | String, Proc?) -> Handler
76
+ def add_observable(name, block = nil)
77
+ observable_name = name.to_sym
78
+ raise InvalidActor, "#{observable_name.inspect} observable is already defined" if observables.key?(observable_name)
79
+
80
+ observable_block = block || -> { state.fetch(observable_name) }
81
+ Handler.new(name: observable_name, block: observable_block).tap do |handler|
82
+ observables[observable_name] = handler
83
+ end
84
+ end
85
+
86
+ # @rbs (Class) -> ActorDefinition
87
+ def synchronize_instance_messages(actor_class)
88
+ names = actor_message_method_names(actor_class)
89
+
90
+ (method_messages.keys - names).each do |name|
91
+ messages.delete(name)
92
+ method_messages.delete(name)
93
+ end
94
+
95
+ names.each { |name| add_method_message(name) unless method_messages.key?(name) }
96
+ self
97
+ end
98
+
99
+ # @rbs (Integer) -> Integer
100
+ def set_state_version(version)
101
+ raise InvalidActor, "state version must be positive" unless version.positive?
102
+
103
+ @state_version = version
104
+ end
105
+
106
+ # @rbs (Integer, Integer, Proc) -> StateMigration
107
+ def add_state_migration(from, to, block)
108
+ unless to == from + 1
109
+ raise InvalidActor, "state migrations must advance exactly one version"
110
+ end
111
+
112
+ if state_migrations.any? { |migration| migration.from == from }
113
+ raise InvalidActor, "state migration from version #{from} is already defined"
114
+ end
115
+
116
+ StateMigration.new(from:, to:, block:).tap { |migration| state_migrations << migration }
117
+ end
118
+
119
+ # @rbs (Integer, Hash[String, untyped]) -> Hash[String, untyped]
120
+ def migrate_state(version, data)
121
+ migrated = Serialization.deep_copy(data)
122
+ current_version = version
123
+
124
+ while current_version < state_version
125
+ migration = state_migrations.find { |candidate| candidate.from == current_version }
126
+ raise StateMigrationError, "missing state migration from version #{current_version}" unless migration
127
+
128
+ migrated = Serialization.dump(migration.block.call(migrated))
129
+ current_version = migration.to
130
+ end
131
+
132
+ if current_version > state_version
133
+ raise StateMigrationError, "stored state version #{current_version} is newer than code version #{state_version}"
134
+ end
135
+
136
+ migrated
137
+ rescue StateMigrationError
138
+ raise
139
+ rescue => error
140
+ raise StateMigrationError, "state migration failed: #{error.message}"
141
+ end
142
+
143
+ # @rbs () -> ActorDefinition
144
+ def duplicate
145
+ self.class.new.tap do |copy|
146
+ copy.instance_variable_set(:@state_definition, state_definition.duplicate)
147
+ copy.instance_variable_set(:@messages, messages.dup)
148
+ copy.instance_variable_set(:@queries, queries.dup)
149
+ copy.instance_variable_set(:@observables, observables.dup)
150
+ copy.instance_variable_set(:@state_version, state_version)
151
+ copy.instance_variable_set(:@state_migrations, state_migrations.dup)
152
+ copy.instance_variable_set(:@activation_hooks, activation_hooks.dup)
153
+ copy.instance_variable_set(:@deactivation_hooks, deactivation_hooks.dup)
154
+ copy.instance_variable_set(:@attribute_queries, attribute_queries.dup)
155
+ copy.instance_variable_set(:@method_messages, method_messages.dup)
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ attr_reader :attribute_queries, :method_messages
162
+
163
+ # @rbs (Symbol) -> Handler
164
+ def add_method_message(name)
165
+ if messages.key?(name) || queries.key?(name)
166
+ raise InvalidActor, "#{name.inspect} is already defined"
167
+ end
168
+
169
+ Handler.new(
170
+ name:,
171
+ block: ->(**arguments) { public_send(name, **arguments) }
172
+ ).tap do |handler|
173
+ messages[name] = handler
174
+ method_messages[name] = true
175
+ end
176
+ end
177
+
178
+ # @rbs (Class) -> Array[Symbol]
179
+ def actor_message_method_names(actor_class)
180
+ actor_ancestors = actor_class.ancestors.take_while { |ancestor| ancestor != Actor }
181
+ attribute_method_names = actor_class.send(:generated_attribute_methods).keys
182
+ validate_attribute_methods!(attribute_method_names)
183
+
184
+ actor_class.public_instance_methods.filter_map do |name|
185
+ method_name = name.to_sym
186
+ next if attribute_method_names.include?(method_name)
187
+ next unless actor_ancestors.include?(actor_class.instance_method(method_name).owner)
188
+
189
+ method_name
190
+ end.uniq
191
+ end
192
+
193
+ # @rbs (Array[Symbol]) -> void
194
+ def validate_attribute_methods!(attribute_method_names)
195
+ state_definition.to_h.each_key do |name|
196
+ [ name, :"#{name}=" ].each do |method_name|
197
+ next if attribute_method_names.include?(method_name)
198
+
199
+ raise InvalidActor, "actor method #{method_name} overrides attribute #{name.inspect}"
200
+ end
201
+ end
202
+ end
203
+
204
+ # @rbs (Hash[Symbol, Handler], Symbol | String, Proc) -> Handler
205
+ def add_handler(collection, name, block)
206
+ handler_name = name.to_sym
207
+ raise InvalidActor, "#{handler_name.inspect} is already defined" if messages.key?(handler_name) || queries.key?(handler_name)
208
+
209
+ Handler.new(name: handler_name, block:).tap { |handler| collection[handler_name] = handler }
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,65 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class ActorRegistry
5
+ # @rbs @actors: Hash[String, Class]
6
+ # @rbs @mutex: Mutex
7
+
8
+ # @rbs () -> void
9
+ def initialize
10
+ @actors = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ # @rbs (String | Symbol, Class) -> Class
15
+ def register(type, actor_class)
16
+ actor_type = normalize(type)
17
+ validate_actor_class!(actor_class)
18
+
19
+ mutex.synchronize do
20
+ existing = actors[actor_type]
21
+ if existing && existing != actor_class
22
+ raise InvalidActor, "#{actor_type.inspect} is already registered by #{existing.name}"
23
+ end
24
+
25
+ actors[actor_type] = actor_class
26
+ end
27
+
28
+ actor_class
29
+ end
30
+
31
+ # @rbs (String | Symbol) -> Class
32
+ def fetch(type)
33
+ actor_type = normalize(type)
34
+ actors.fetch(actor_type) { raise UnknownActorType, "unknown actor type #{actor_type.inspect}" }
35
+ end
36
+
37
+ # @rbs (String | Symbol) -> bool
38
+ def registered?(type)
39
+ actors.key?(normalize(type))
40
+ end
41
+
42
+ # @rbs () -> Hash[String, Class]
43
+ def to_h
44
+ mutex.synchronize { actors.dup.freeze }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :actors, :mutex
50
+
51
+ # @rbs (String | Symbol) -> String
52
+ def normalize(type)
53
+ type.to_s.tap do |value|
54
+ raise InvalidActor, "actor type cannot be empty" if value.empty?
55
+ end
56
+ end
57
+
58
+ # @rbs (untyped) -> void
59
+ def validate_actor_class!(actor_class)
60
+ return if actor_class.is_a?(Class) && actor_class < Actor
61
+
62
+ raise InvalidActor, "registered actor must inherit from SolidObjects::Actor"
63
+ end
64
+ end
65
+ end