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
data/docs/security.md ADDED
@@ -0,0 +1,61 @@
1
+ # Security guide
2
+
3
+ ## Policy hooks
4
+
5
+ Solid Objects has separate hooks for sending messages, querying state,
6
+ destroying actors, subscribing to actor streams, and administration. The host
7
+ application supplies the authenticated request or connection as
8
+ `authorization_context`. All five hooks deny by default.
9
+
10
+ Method-style reference calls do not bypass these hooks. Public instance methods
11
+ declared on an actor are part of its remotely addressable message surface and
12
+ delegate to the authorized `tell` path. Keep implementation helpers private or
13
+ protected. Query and attribute methods delegate to the authorized `ask` path.
14
+ `reference.destroy` delegates to `authorize_destroy` before checking whether
15
+ the actor exists, so denial does not reveal actor existence.
16
+
17
+ Internal runtime delivery bypasses the public client only for rows already
18
+ created by a committed actor turn. It never converts a database actor type into
19
+ a Ruby constant.
20
+
21
+ ## Actor identity
22
+
23
+ Actor IDs are identifiers, not capabilities. Treat user-supplied IDs as
24
+ untrusted and verify tenancy/ownership in every public policy hook. The registry
25
+ maps explicit type strings to actor classes; arbitrary constantization is
26
+ forbidden.
27
+
28
+ Opaque stream and DOM names reduce accidental disclosure but do not replace
29
+ authorization. Signed stream tokens are readable by their recipient and prove
30
+ integrity only.
31
+
32
+ ## Serialization
33
+
34
+ The built-in serializer accepts JSON-compatible data, normalizes keys to
35
+ strings, rejects colliding normalized keys, limits nesting and byte size, and
36
+ rejects non-finite numbers. It never loads Ruby objects with `Marshal`.
37
+
38
+ Database access is still privileged input. Restrict who can write runtime
39
+ tables, because a malicious row can request registered messages or effects even
40
+ without unsafe object deserialization.
41
+
42
+ ## Administration
43
+
44
+ The engine controllers and dead-letter service deny access unless
45
+ `authorize_administration` approves. Actor state, arguments, results, errors,
46
+ and backtraces may contain sensitive application data. Keep admin routes behind
47
+ host authentication and audit their use.
48
+
49
+ Instrumentation excludes arguments, state, results, and effect payloads by
50
+ default. Review custom logging and effect handlers for accidental disclosure.
51
+
52
+ Actor destruction is not an administrative shortcut. Authorize tenancy and
53
+ ownership explicitly in `authorize_destroy`; knowledge of an actor ID is never
54
+ permission to delete its state or queued work.
55
+
56
+ ## Denial of service
57
+
58
+ Configure mailbox and byte limits. Add host rate limiting before public actor
59
+ calls. One actor is intentionally sequential, so an attacker who can target a
60
+ single identity can create a hot-actor bottleneck even when the global worker
61
+ pool has capacity.
@@ -0,0 +1,46 @@
1
+ # State migration guide
2
+
3
+ Declare a monotonically increasing version and every adjacent migration:
4
+
5
+ ```ruby
6
+ class ShoppingCartActor < SolidObjects::Actor
7
+ state_version 2
8
+
9
+ migrate_state from: 1, to: 2 do |state|
10
+ state["currency"] ||= "USD"
11
+ state
12
+ end
13
+ end
14
+ ```
15
+
16
+ Migration runs in memory during activation. The new version is persisted only
17
+ with the next successful fenced message commit.
18
+
19
+ ## Runtime rules
20
+
21
+ - Stored state newer than running code refuses activation.
22
+ - Missing migration steps fail activation.
23
+ - Every migration result must pass JSON serialization.
24
+ - Additive representation changes that old and new code both understand need
25
+ no version bump.
26
+ - Destructive changes require expand/contract releases.
27
+ - Published actor migrations are never squashed. A year-idle actor may still
28
+ hold a year-old blob.
29
+
30
+ ## Rolling deploys
31
+
32
+ A new worker can migrate state while an old worker still exists. If the old code
33
+ cannot read the migrated shape, drain the old workers before allowing the new
34
+ version to persist it.
35
+
36
+ A safe destructive rollout normally uses:
37
+
38
+ 1. Expand readers to accept old and new shapes.
39
+ 2. Deploy that compatibility release everywhere.
40
+ 3. Add and enable the actor migration.
41
+ 4. Allow active and dormant actors to migrate over time, or run an authorized
42
+ message-based migration campaign.
43
+ 5. Remove old-shape support only after operational evidence says it is safe.
44
+
45
+ Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
46
+ ordering, observables, and outboxes remain intact.
@@ -0,0 +1,16 @@
1
+ # Example application source
2
+
3
+ This directory contains the host-application pieces for the shopping cart and
4
+ chat room examples. Copy them into a Rails application that has run
5
+ `bin/rails generate solid_objects:install`, mount the engine for administration,
6
+ and start the runtime with `bundle exec solid_objects start`.
7
+
8
+ The payment adapter receives the durable effect ID as its provider idempotency
9
+ key. The chat actor also gives every submitted chat message a caller-generated
10
+ message ID and checks that ID in durable actor state, because actor handlers may
11
+ be redelivered.
12
+
13
+ The views demonstrate initial `solid_object` rendering and live observable
14
+ replacement. Live cart-summary replacement and chat-message append are
15
+ deliberately not shown as working behavior because component broadcasts and
16
+ Turbo append intents remain roadmap items.
@@ -0,0 +1,34 @@
1
+ # rbs_inline: enabled
2
+
3
+ class ChatRoomActor < SolidObjects::Actor
4
+ MAX_RECENT_MESSAGES = 100
5
+
6
+ attribute :members, default: -> { [] }
7
+ attribute :recent_messages, default: -> { [] }
8
+
9
+ observable :presence do
10
+ members.length
11
+ end
12
+
13
+ observable :recent_messages
14
+
15
+ def join(user_id:)
16
+ members << user_id unless members.include?(user_id)
17
+ end
18
+
19
+ def leave(user_id:)
20
+ members.delete(user_id)
21
+ end
22
+
23
+ def send_message(message_id:, user_id:, body:)
24
+ return if recent_messages.any? { |message| message.fetch("id") == message_id }
25
+ return unless members.include?(user_id)
26
+
27
+ recent_messages << {
28
+ "id" => message_id,
29
+ "user_id" => user_id,
30
+ "body" => body
31
+ }
32
+ recent_messages.shift while recent_messages.length > MAX_RECENT_MESSAGES
33
+ end
34
+ end
@@ -0,0 +1,79 @@
1
+ # rbs_inline: enabled
2
+
3
+ class ShoppingCartActor < SolidObjects::Actor
4
+ attribute :items, default: -> { [] }
5
+ attribute :checkout_status, default: "open"
6
+ attribute :payment_id
7
+
8
+ observable :items_count do
9
+ items.sum { |item| item.fetch("quantity") }
10
+ end
11
+
12
+ observable :subtotal_cents do
13
+ items.sum do |item|
14
+ item.fetch("quantity") * item.fetch("unit_price_cents")
15
+ end
16
+ end
17
+
18
+ observable :checkout_status
19
+
20
+ def add_item(product_id:, unit_price_cents:, quantity: 1)
21
+ item = items.find do |candidate|
22
+ candidate.fetch("product_id") == product_id
23
+ end
24
+
25
+ if item
26
+ item["quantity"] += quantity
27
+ else
28
+ items << {
29
+ "product_id" => product_id,
30
+ "unit_price_cents" => unit_price_cents,
31
+ "quantity" => quantity
32
+ }
33
+ end
34
+ end
35
+
36
+ def remove_item(product_id:)
37
+ items.reject! do |item|
38
+ item.fetch("product_id") == product_id
39
+ end
40
+ end
41
+
42
+ def change_quantity(product_id:, quantity:)
43
+ item = items.find do |candidate|
44
+ candidate.fetch("product_id") == product_id
45
+ end
46
+ return unless item
47
+
48
+ item["quantity"] = quantity
49
+ end
50
+
51
+ def checkout(payment_id:)
52
+ return unless checkout_status == "open"
53
+
54
+ self.checkout_status = "pending"
55
+ self.payment_id = payment_id
56
+ emit(
57
+ :charge_payment,
58
+ payment_id:,
59
+ amount_cents: items.sum do |item|
60
+ item.fetch("quantity") * item.fetch("unit_price_cents")
61
+ end,
62
+ on_success: :payment_succeeded,
63
+ on_failure: :payment_failed
64
+ )
65
+ end
66
+
67
+ def payment_succeeded(effect_id:, result:)
68
+ return unless checkout_status == "pending"
69
+ return unless result.fetch("payment_id") == payment_id
70
+
71
+ self.checkout_status = "paid"
72
+ end
73
+
74
+ def payment_failed(effect_id:, error:)
75
+ return unless checkout_status == "pending"
76
+
77
+ self.checkout_status = "payment_failed"
78
+ end
79
+ end
@@ -0,0 +1,54 @@
1
+ # rbs_inline: enabled
2
+
3
+ class CartController < ApplicationController
4
+ # @rbs () -> void
5
+ def show
6
+ @cart = ShoppingCartActor.ref(current_user.id)
7
+ end
8
+
9
+ # @rbs () -> void
10
+ def add_item
11
+ ShoppingCartActor.ref(current_user.id).add_item(
12
+ product_id: params.require(:product_id),
13
+ unit_price_cents: params.require(:unit_price_cents).to_i,
14
+ quantity: params.fetch(:quantity, 1).to_i,
15
+ authorization_context: self
16
+ )
17
+ redirect_to cart_path
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def remove_item
22
+ current_cart.remove_item(
23
+ product_id: params.require(:product_id),
24
+ authorization_context: self
25
+ )
26
+ redirect_to cart_path
27
+ end
28
+
29
+ # @rbs () -> void
30
+ def change_quantity
31
+ current_cart.change_quantity(
32
+ product_id: params.require(:product_id),
33
+ quantity: params.require(:quantity).to_i,
34
+ authorization_context: self
35
+ )
36
+ redirect_to cart_path
37
+ end
38
+
39
+ # @rbs () -> void
40
+ def checkout
41
+ current_cart.checkout(
42
+ payment_id: SecureRandom.uuid,
43
+ authorization_context: self
44
+ )
45
+ redirect_to cart_path
46
+ end
47
+
48
+ private
49
+
50
+ # @rbs () -> SolidObjects::Reference
51
+ def current_cart
52
+ ShoppingCartActor.ref(current_user.id)
53
+ end
54
+ end
@@ -0,0 +1,44 @@
1
+ # rbs_inline: enabled
2
+
3
+ class ChatRoomsController < ApplicationController
4
+ # @rbs () -> void
5
+ def show
6
+ @room = current_room
7
+ end
8
+
9
+ # @rbs () -> void
10
+ def join
11
+ current_room.join(
12
+ user_id: current_user.id.to_s,
13
+ authorization_context: self
14
+ )
15
+ redirect_to chat_room_path(params[:id])
16
+ end
17
+
18
+ # @rbs () -> void
19
+ def leave
20
+ current_room.leave(
21
+ user_id: current_user.id.to_s,
22
+ authorization_context: self
23
+ )
24
+ redirect_to chat_room_path(params[:id])
25
+ end
26
+
27
+ # @rbs () -> void
28
+ def create_message
29
+ current_room.send_message(
30
+ message_id: SecureRandom.uuid,
31
+ user_id: current_user.id.to_s,
32
+ body: params.require(:body),
33
+ authorization_context: self
34
+ )
35
+ redirect_to chat_room_path(params[:id])
36
+ end
37
+
38
+ private
39
+
40
+ # @rbs () -> SolidObjects::Reference
41
+ def current_room
42
+ ChatRoomActor.ref(params.require(:id))
43
+ end
44
+ end
@@ -0,0 +1,8 @@
1
+ <ol>
2
+ <% actor.state.recent_messages.each do |message| %>
3
+ <li id="message_<%= message.fetch("id") %>">
4
+ <strong><%= message.fetch("user_id") %></strong>
5
+ <%= message.fetch("body") %>
6
+ </li>
7
+ <% end %>
8
+ </ol>
@@ -0,0 +1,10 @@
1
+ <ul>
2
+ <% actor.state.items.each do |item| %>
3
+ <li>
4
+ <%= item.fetch("product_id") %>
5
+ × <%= item.fetch("quantity") %>
6
+ </li>
7
+ <% end %>
8
+ </ul>
9
+
10
+ <p>Checkout: <%= actor.state.checkout_status %></p>
@@ -0,0 +1,13 @@
1
+ <%= solid_object @cart do |cart| %>
2
+ <span>
3
+ Cart items:
4
+ <%= cart.items_count %>
5
+ </span>
6
+
7
+ <span>
8
+ Subtotal:
9
+ <%= cart.subtotal_cents %>
10
+ </span>
11
+
12
+ <%= cart.component :summary %>
13
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <%= solid_object @room do |room| %>
2
+ <p>
3
+ Present:
4
+ <%= room.presence %>
5
+ </p>
6
+
7
+ <%= room.component :messages %>
8
+ <% end %>
@@ -0,0 +1,23 @@
1
+ # rbs_inline: enabled
2
+
3
+ SolidObjects.configure do |configuration|
4
+ configuration.authorize_message = lambda do |actor_type:, actor_id:, authorization_context:, **|
5
+ next false unless authorization_context.respond_to?(:current_user)
6
+
7
+ if actor_type == ShoppingCartActor.actor_type
8
+ authorization_context.current_user.id.to_s == actor_id
9
+ else
10
+ ChatRoomPolicy.new(authorization_context.current_user).access?(actor_id)
11
+ end
12
+ end
13
+ configuration.authorize_query = configuration.authorize_message
14
+ configuration.authorize_subscription = configuration.authorize_message
15
+ end
16
+
17
+ SolidObjects.register_effect(:charge_payment) do |arguments, context|
18
+ Payments.charge(
19
+ idempotency_key: context.id,
20
+ payment_id: arguments.fetch("payment_id"),
21
+ amount_cents: arguments.fetch("amount_cents")
22
+ )
23
+ end
@@ -0,0 +1,20 @@
1
+ # rbs_inline: enabled
2
+
3
+ Rails.application.routes.draw do
4
+ resource :cart, only: :show do
5
+ post :add_item
6
+ post :remove_item
7
+ post :change_quantity
8
+ post :checkout
9
+ end
10
+
11
+ resources :chat_rooms, only: :show do
12
+ member do
13
+ post :join
14
+ post :leave
15
+ post :create_message
16
+ end
17
+ end
18
+
19
+ mount SolidObjects::Engine => "/solid_objects"
20
+ end
data/exe/solid_objects ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # rbs_inline: enabled
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "solid_objects"
7
+ require "solid_objects/cli"
8
+
9
+ SolidObjects::CLI.start(ARGV)
@@ -0,0 +1,21 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "rails/generators"
4
+
5
+ module SolidObjects
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ # @rbs () -> void
11
+ def copy_initializer
12
+ template "solid_objects.rb", "config/initializers/solid_objects.rb"
13
+ end
14
+
15
+ # @rbs () -> void
16
+ def copy_migrations
17
+ rake "railties:install:migrations FROM=solid_objects"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ # rbs_inline: enabled
2
+
3
+ SolidObjects.configure do |configuration|
4
+ configuration.worker_count = 1
5
+ configuration.effect_worker_count = 1
6
+ configuration.broadcast_worker_count = 1
7
+ configuration.reminder_scheduler_count = 1
8
+ configuration.authorize_message = ->(**) { false }
9
+ configuration.authorize_query = ->(**) { false }
10
+ configuration.authorize_destroy = ->(**) { false }
11
+ configuration.authorize_subscription = ->(**) { false }
12
+ configuration.authorize_administration = ->(**) { false }
13
+ end
@@ -0,0 +1,19 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "action_cable/engine"
4
+
5
+ module SolidObjects
6
+ class ActionCableBroadcastAdapter
7
+ # @rbs (Broadcast) -> void
8
+ def call(broadcast)
9
+ reference = Reference.new(
10
+ actor_type: broadcast.instance.actor_type,
11
+ actor_id: broadcast.instance.actor_id
12
+ )
13
+ ActionCable.server.broadcast(
14
+ StreamName.for(reference),
15
+ TurboStreamRenderer.observable(broadcast)
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,183 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Activation
5
+ # @rbs @lease: Lease
6
+ # @rbs @actor_class: Class
7
+ # @rbs @actor: Actor
8
+ # @rbs @last_used_at: Float
9
+ # @rbs @pass_exhausted: bool
10
+
11
+ attr_reader :lease, :actor
12
+
13
+ # @rbs (lease: Lease) -> void
14
+ def initialize(lease:)
15
+ @lease = lease
16
+ instance = Instance.find(lease.instance_id)
17
+ @actor_class = SolidObjects.registry.fetch(instance.actor_type)
18
+ @actor = build_actor(instance)
19
+ @last_used_at = monotonic_now
20
+ @pass_exhausted = false
21
+ actor.activate
22
+ SolidObjects.instrument(
23
+ :"activation.started",
24
+ instance_id: instance.id,
25
+ actor_type: instance.actor_type,
26
+ actor_id: instance.actor_id,
27
+ owner_id: lease.owner_id,
28
+ generation: lease.generation
29
+ )
30
+ end
31
+
32
+ # @rbs () -> Integer
33
+ def drain
34
+ processed_count = 0
35
+ started_at = monotonic_now
36
+ @pass_exhausted = false
37
+
38
+ loop do
39
+ if processed_count >= SolidObjects.configuration.max_messages_per_activation_pass ||
40
+ monotonic_now - started_at >= SolidObjects.configuration.max_activation_duration
41
+ @pass_exhausted = true
42
+ break
43
+ end
44
+
45
+ message = claim_next_message
46
+ break unless message
47
+
48
+ Executor.new(activation: self, message:).call
49
+ processed_count += 1
50
+ @last_used_at = monotonic_now
51
+ end
52
+
53
+ processed_count
54
+ end
55
+
56
+ # @rbs () -> bool
57
+ def ready?
58
+ now = SolidObjects.database_adapter.database_now
59
+ ReadyMessage.where(instance_id: lease.instance_id, available_at: ..now).exists? ||
60
+ ClaimedMessage.where(
61
+ instance_id: lease.instance_id,
62
+ process_id: lease.owner_id,
63
+ activation_generation: lease.generation
64
+ ).exists?
65
+ end
66
+
67
+ # @rbs () -> bool
68
+ def pass_exhausted?
69
+ @pass_exhausted
70
+ end
71
+
72
+ # @rbs () -> bool
73
+ def idle?
74
+ monotonic_now - @last_used_at >= SolidObjects.configuration.idle_deactivation_timeout
75
+ end
76
+
77
+ # @rbs () -> void
78
+ def renew_lease
79
+ @lease = lease.renew
80
+ end
81
+
82
+ # @rbs () -> bool
83
+ def lease_renewal_due?
84
+ lease.expires_at - SolidObjects.database_adapter.database_now <=
85
+ SolidObjects.configuration.lease_renewal_interval
86
+ end
87
+
88
+ # @rbs () -> void
89
+ def yield_ready_messages
90
+ now = SolidObjects.database_adapter.database_now
91
+ ReadyMessage
92
+ .where(instance_id: lease.instance_id, available_at: ..now)
93
+ .update_all(available_at: now)
94
+ end
95
+
96
+ # @rbs (Hash[String, untyped]) -> void
97
+ def restore_state(state_data)
98
+ @actor = actor_class.new(
99
+ actor_id: actor.actor_id,
100
+ state: State.new(actor_class.definition.state_definition, state_data)
101
+ )
102
+ end
103
+
104
+ # @rbs () -> void
105
+ def deactivate
106
+ actor.deactivate
107
+ lease.release
108
+ rescue LostActivation
109
+ nil
110
+ end
111
+
112
+ private
113
+
114
+ attr_reader :actor_class
115
+
116
+ # @rbs (Instance) -> Actor
117
+ def build_actor(instance)
118
+ state_data = actor_class.definition.migrate_state(instance.state_version, instance.state)
119
+ actor_class.new(
120
+ actor_id: instance.actor_id,
121
+ state: State.new(actor_class.definition.state_definition, state_data)
122
+ )
123
+ end
124
+
125
+ # @rbs () -> Message?
126
+ def claim_next_message
127
+ lease.fenced_transaction do |instance|
128
+ recover_stale_claim(instance)
129
+ ready_message = ReadyMessage
130
+ .where(instance_id: instance.id)
131
+ .order(:sequence)
132
+ .first
133
+ next unless ready_message
134
+
135
+ now = SolidObjects.database_adapter.database_now
136
+ next if ready_message.available_at > now
137
+
138
+ message = Message.lock.find(ready_message.message_id)
139
+ ready_message.destroy!
140
+ message.update!(
141
+ attempt_count: message.attempt_count + 1,
142
+ started_at: now
143
+ )
144
+ ClaimedMessage.create!(
145
+ message:,
146
+ instance:,
147
+ process_id: lease.owner_id,
148
+ activation_generation: lease.generation,
149
+ claimed_at: now
150
+ )
151
+ message
152
+ end
153
+ end
154
+
155
+ # @rbs (Instance) -> void
156
+ def recover_stale_claim(instance)
157
+ claimed_message = ClaimedMessage
158
+ .joins(:message)
159
+ .where(instance_id: instance.id)
160
+ .order("#{Message.table_name}.sequence")
161
+ .first
162
+ return unless claimed_message
163
+ return if claimed_message.process_id == lease.owner_id &&
164
+ claimed_message.activation_generation == lease.generation
165
+
166
+ message = claimed_message.message
167
+ claimed_message.destroy!
168
+ return if message.completed? || message.dead?
169
+
170
+ ReadyMessage.create!(
171
+ message:,
172
+ instance:,
173
+ sequence: message.sequence,
174
+ available_at: SolidObjects.database_adapter.database_now
175
+ )
176
+ end
177
+
178
+ # @rbs () -> Float
179
+ def monotonic_now
180
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
181
+ end
182
+ end
183
+ end