solid_objects 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +62 -20
  4. data/app/controllers/solid_objects/components_controller.rb +4 -4
  5. data/app/models/solid_objects/broadcast.rb +8 -0
  6. data/app/models/solid_objects/message.rb +1 -1
  7. data/app/views/solid_objects/dead_letters/index.html.erb +1 -1
  8. data/app/views/solid_objects/instances/show.html.erb +1 -1
  9. data/benchmark/support.rb +10 -10
  10. data/db/migrate/20260813000000_rename_message_dispatch_columns.rb +53 -0
  11. data/docs/architecture.md +14 -11
  12. data/docs/authorization.md +2 -2
  13. data/docs/database-schema.md +6 -6
  14. data/docs/development.md +13 -1
  15. data/docs/migrating-existing-state.md +1 -3
  16. data/docs/realtime.md +25 -3
  17. data/docs/roadmap.md +9 -5
  18. data/docs/security.md +8 -0
  19. data/examples/application/app/actors/shopping_cart_actor.rb +3 -3
  20. data/lib/solid_objects/actor.rb +49 -37
  21. data/lib/solid_objects/actor_channel.rb +10 -7
  22. data/lib/solid_objects/actor_definition.rb +24 -9
  23. data/lib/solid_objects/actor_view.rb +7 -2
  24. data/lib/solid_objects/application_write_guard.rb +1 -1
  25. data/lib/solid_objects/client.rb +52 -52
  26. data/lib/solid_objects/component_registration.rb +2 -2
  27. data/lib/solid_objects/component_renderer.rb +1 -1
  28. data/lib/solid_objects/component_subscriptions.rb +21 -21
  29. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  30. data/lib/solid_objects/dead_letter_manager.rb +4 -4
  31. data/lib/solid_objects/doctor.rb +8 -8
  32. data/lib/solid_objects/effect_executor.rb +30 -22
  33. data/lib/solid_objects/errors.rb +30 -27
  34. data/lib/solid_objects/executor.rb +47 -39
  35. data/lib/solid_objects/lease.rb +3 -3
  36. data/lib/solid_objects/mailbox.rb +34 -27
  37. data/lib/solid_objects/operation_dispatcher.rb +51 -0
  38. data/lib/solid_objects/payload_broadcast.rb +1 -1
  39. data/lib/solid_objects/reference.rb +60 -29
  40. data/lib/solid_objects/reminder_scheduler.rb +34 -20
  41. data/lib/solid_objects/sync_diagnostics.rb +6 -6
  42. data/lib/solid_objects/test_helper.rb +16 -0
  43. data/lib/solid_objects/turbo_stream_renderer.rb +16 -16
  44. data/lib/solid_objects/version.rb +1 -1
  45. data/lib/solid_objects.rb +1 -0
  46. data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
  47. data/sig/generated/lib/solid_objects/actor.rbs +19 -19
  48. data/sig/generated/lib/solid_objects/actor_definition.rbs +13 -6
  49. data/sig/generated/lib/solid_objects/client.rbs +8 -8
  50. data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
  51. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
  52. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  53. data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
  54. data/sig/generated/lib/solid_objects/errors.rbs +21 -18
  55. data/sig/generated/lib/solid_objects/executor.rbs +10 -10
  56. data/sig/generated/lib/solid_objects/lease.rbs +2 -2
  57. data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
  58. data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
  59. data/sig/generated/lib/solid_objects/reference.rbs +7 -4
  60. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
  61. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
  62. data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
  63. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
  64. data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
  65. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 530d40a11cbf715cc3657191124d336798d4e8efdccb129d8ee0431400ec36ff
4
- data.tar.gz: 39abe950312fa41ab57aa55e7e01a088a01eb63dcfd535e0ada712ae75c03b01
3
+ metadata.gz: 7e89f40dd095ec1d8934ff47872f3638ca441203186a7a1562dc9f603c939150
4
+ data.tar.gz: 23389e8737fcbfc816ca41aedf56f4b83baa6769fc2c54f6d9ca3622c67ca135
5
5
  SHA512:
6
- metadata.gz: edd288ba3cc57f8ac7c241eda9e3e320385f26071a5dc1e11e3676af1106dc1ddadddfa520ff08f4ce61a0b2f73c23d1a2418199c8f6f98ebe85fe2b51b07426
7
- data.tar.gz: 38e5561275048f00dcbd5e363b0d3da6a00242348094776c63da44df7e2ca694292e7bc0546d44ac5da7a8883eb70d3e237d7495c888d6d3e2dd9d286d1754f0
6
+ metadata.gz: d28e8e826c045dc546a13eb34631c692281b82b10a55205f0f6b3a17b77a1a2144bbfb786c69c86d701d42d356c65da4c26d7dd2f25a5538f9743e4405aa53b1
7
+ data.tar.gz: 34c89447e1592ea056d511db1fbf01de898c1563c34616a7b80058b0c1bb649b3a4e01cf7f1fad6de35bedc82e484c0a8c96b21aa0be1d959b94c3492fd3d3c4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1 - 2026-08-13
4
+
5
+ - Add invalidation-only observables with `broadcast: :invalidation`. They still
6
+ detect changes and refresh reactive components, but persist `{}` and send no
7
+ scalar value over Action Cable. Document that ordinary observable values are
8
+ shared with every authorized actor subscriber and that subscriber-specific
9
+ state belongs in a payload projection.
10
+ - **Breaking:** include the originally staged `arguments:` in effect success
11
+ and failure callbacks so actors can correlate concurrent effects.
12
+ - Accept identifier-style rejection codes, including camelCase and symbols,
13
+ and fail malformed codes once with non-retryable
14
+ `SolidObjects::InvalidRejectionCode` diagnostics.
15
+ - Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
16
+ reminder tests without sleeping or mutating runtime rows.
17
+
18
+ ## 0.12.0 - 2026-08-13
19
+
20
+ - Replace positional actor dispatch with fluent operation selection. Direct
21
+ committed calls remain `account.disable(...)`; configured committed calls
22
+ use `account.sync(timeout: ...).status`; asynchronous calls use
23
+ `account.async(...).disable(...)`; actor outbox delivery uses
24
+ `send_to(account, ...).disable(...)`; and reminders use
25
+ `schedule(at: ...).evaluate(...)`. Delivery and reminder options are now
26
+ unambiguously separate from actor message arguments. The former positional
27
+ `async`, `sync`, `send_to`, and `schedule` forms are removed.
28
+ - Validate fluent operations before enqueueing or staging them. Direct and
29
+ configured synchronous calls accept messages and queries, while `async`,
30
+ `send_to`, and `schedule` accept public actor messages only.
31
+ - Use operation terminology throughout invocation persistence and diagnostics.
32
+ The new migration renames stored message names to `operation`, message kind
33
+ to `delivery_mode`, and effect callback message names to operation names.
34
+ - Make internal methods with more than two arguments keyword-only so dispatch,
35
+ persistence, component refresh, and diagnostics call sites name every value.
36
+ - Keep SQLite reconnect failures inside a synchronous lock deadline. Active
37
+ Record can reconnect after a lock error while the lock is still held, and
38
+ configuring WAL then raises `SQLite3::CantOpenException`; it now retries
39
+ within the original deadline and surfaces the documented sync timeout.
40
+
3
41
  ## 0.11.0 - 2026-08-11
4
42
 
5
43
  - Add `SolidObjects.configuration.register_component`. An extension gem can now
data/README.md CHANGED
@@ -27,7 +27,7 @@ current_count = counter.value
27
27
  current_snapshot = counter.snapshot.value
28
28
 
29
29
  # Durable fire-and-forget delivery. A worker processes it later.
30
- message = counter.async(:increment, amount: 5)
30
+ message = counter.async.increment(amount: 5)
31
31
  ```
32
32
 
33
33
  `Counter / global` is a logical identity. Like a Durable Object named with
@@ -41,10 +41,10 @@ The invocation model is the first adoption decision:
41
41
  | Call | Returns | Worker fleet required? |
42
42
  | --- | --- | --- |
43
43
  | `counter.increment(amount: 5)` | Committed handler result | No |
44
- | `counter.sync(:increment, amount: 5)` | Committed handler result | No |
44
+ | `counter.sync(timeout: 5.seconds).increment(amount: 5)` | Committed handler result | No |
45
45
  | `counter.value` | Ordered, committed query result | No |
46
46
  | `counter.snapshot.value` | Current committed state without a mailbox message | No |
47
- | `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
47
+ | `counter.async.increment(amount: 5)` | `MessageReference` immediately | Yes |
48
48
 
49
49
  Direct methods and `sync` durably enqueue the call, then the Rails caller helps
50
50
  execute the actor through the same mailbox, lease, and fencing path as a
@@ -155,7 +155,7 @@ durable reminders owned by one logical identity:
155
155
 
156
156
  ```ruby
157
157
  def schedule_expiration
158
- schedule :expire, at: 30.minutes.from_now, arguments: {}
158
+ schedule(at: 30.minutes.from_now).expire
159
159
  end
160
160
  ```
161
161
 
@@ -205,6 +205,21 @@ dependencies changes:
205
205
  <% end %>
206
206
  ```
207
207
 
208
+ An observable's value is shared with every authorized subscriber by default
209
+ and is stored in `solid_objects_broadcasts`. Use an invalidation-only observable
210
+ for component dependencies whose value is private or subscriber-specific:
211
+
212
+ ```ruby
213
+ observable :player_one, broadcast: :invalidation do
214
+ player_in_seat(1)
215
+ end
216
+ ```
217
+
218
+ Its value is still available to the authorized component renderer, but the
219
+ durable row and Action Cable frame carry only invalidation metadata. It cannot
220
+ be rendered as a scalar `<span>`. Put per-viewer state in `broadcast_payload`,
221
+ which computes a fresh projection for each connection.
222
+
208
223
  Component names can repeat when each instance has a stable key. Signed
209
224
  JSON-compatible locals let one conventional partial render the matching
210
225
  projection:
@@ -536,7 +551,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
536
551
  items = cart.items
537
552
  ```
538
553
 
539
- Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
554
+ Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
540
555
  without waiting; that call returns a `SolidObjects::MessageReference`. `items`
541
556
  is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
542
557
  mailbox. State changes must go through public actor methods or explicit
@@ -638,26 +653,34 @@ Use `async` for durable fire-and-forget work. It returns a
638
653
 
639
654
  ```ruby
640
655
  message = order.async(
641
- :submit,
642
- idempotency_key: "submit-order-123"
643
- )
656
+ idempotency_key: "submit-order-123",
657
+ authorization_context: Current.user
658
+ ).submit
644
659
  ```
645
660
 
646
661
  Use `available_at:` to spread bulk work or delay one message:
647
662
 
648
663
  ```ruby
649
- order.async(:evaluate, available_at: 10.minutes.from_now)
664
+ order.async(available_at: 10.minutes.from_now).evaluate
650
665
  ```
651
666
 
652
667
  ### `sync`
653
668
 
654
- Use explicit `sync` when the operation name is dynamic or collides with a
655
- reference method:
669
+ Use explicit `sync` when the invocation needs a timeout, idempotency key, or
670
+ authorization context different from the defaults:
656
671
 
657
672
  ```ruby
658
- status = order.sync(:status, timeout: 5.seconds)
673
+ status = order.sync(
674
+ timeout: 5.seconds,
675
+ authorization_context: Current.user
676
+ ).status
659
677
  ```
660
678
 
679
+ Delivery configuration belongs on `async(...)` or `sync(...)` before the
680
+ operation. Keywords on the final method call are always actor message
681
+ arguments, so `order.sync(timeout: 5.seconds).record(timeout: "payload")`
682
+ keeps the invocation timeout separate from the payload value.
683
+
661
684
  Direct calls and `sync` use the same caller-assisted execution path. A healthy
662
685
  actor normally needs no worker round trip, making this path suitable for HTTP
663
686
  and MCP request/response boundaries when the handler itself fits the
@@ -677,7 +700,7 @@ recover its eventual result through the durable message identity:
677
700
 
678
701
  ```ruby
679
702
  begin
680
- order.submit(timeout: 250.milliseconds)
703
+ order.sync(timeout: 250.milliseconds).submit
681
704
  rescue SolidObjects::SyncTimeout => error
682
705
  result = error.message_reference.wait(
683
706
  timeout: 5.seconds,
@@ -700,6 +723,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
700
723
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
701
724
  result message.
702
725
 
726
+ ```ruby
727
+ send_to(
728
+ audit_log,
729
+ available_at: 5.minutes.from_now,
730
+ idempotency_key: event_id
731
+ ).record(event_id:, event_name: "account_disabled")
732
+ ```
733
+
734
+ Actor-to-actor delivery is staged with the current turn, returns `nil`, and is
735
+ discarded if that turn does not commit. It accepts messages, not queries.
736
+
703
737
  ### Domain rejection
704
738
 
705
739
  Reject invalid input without retrying or creating a dead letter:
@@ -717,8 +751,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
717
751
  state is rolled back, and no later mailbox turn is blocked.
718
752
 
719
753
  `Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
720
- match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
721
- handler calls `reject`.
754
+ match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
755
+ `SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
722
756
 
723
757
  ### Redelivery
724
758
 
@@ -800,11 +834,11 @@ def checkout(payment_id:, amount_cents:)
800
834
  )
801
835
  end
802
836
 
803
- def payment_succeeded(effect_id:, result:)
837
+ def payment_succeeded(effect_id:, arguments:, result:)
804
838
  self.checkout_status = "paid"
805
839
  end
806
840
 
807
- def payment_failed(effect_id:, error:)
841
+ def payment_failed(effect_id:, arguments:, error:)
808
842
  self.checkout_status = "failed"
809
843
  end
810
844
  ```
@@ -823,6 +857,10 @@ end
823
857
 
824
858
  The provider call can repeat if a process dies after external success but
825
859
  before recording completion. The stable effect ID is the idempotency key.
860
+ Success callbacks receive `effect_id:`, the originally staged `arguments:`,
861
+ and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
862
+ `error:`, so an actor can correlate concurrent effects without storing a
863
+ separate callback ledger.
826
864
 
827
865
  ## Reminders
828
866
 
@@ -831,7 +869,11 @@ API. One-shot and recurring alarms are actor-owned database records:
831
869
 
832
870
  ```ruby
833
871
  def schedule_evaluation
834
- schedule :evaluate, at: 1.hour.from_now, every: 1.hour, missed: :latest
872
+ schedule(
873
+ at: 1.hour.from_now,
874
+ every: 1.hour,
875
+ missed: :latest
876
+ ).evaluate(account_id:)
835
877
  end
836
878
  ```
837
879
 
@@ -852,7 +894,7 @@ once. It also means this is a data-loss bug:
852
894
  # Wrong. Every entry overwrites the previous entry's alarm.
853
895
  def add(entry:)
854
896
  self.entries = entries + [ entry ]
855
- schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
897
+ schedule(at: entry.fetch("wait_until")).deliver
856
898
  end
857
899
  ```
858
900
 
@@ -882,7 +924,7 @@ def arm_next
882
924
  earliest = entries.first
883
925
  return unless earliest
884
926
 
885
- schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
927
+ schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
886
928
  end
887
929
  ```
888
930
 
@@ -55,7 +55,7 @@ module SolidObjects
55
55
  "target" => registration.dom_id,
56
56
  "revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
57
57
  "refresh_method" => registration.refresh_method,
58
- "html" => component_frame(registration, snapshot, rendered)
58
+ "html" => component_frame(registration:, snapshot:, rendered:)
59
59
  }
60
60
  end
61
61
  response.headers["Cache-Control"] = "private, no-store"
@@ -118,7 +118,7 @@ module SolidObjects
118
118
  ).call
119
119
  response.headers["Cache-Control"] = "private, no-store"
120
120
  payload[:outcome] = "rendered"
121
- render html: component_frame(registration, snapshot, rendered)
121
+ render html: component_frame(registration:, snapshot:, rendered:)
122
122
  rescue Unauthorized
123
123
  payload[:outcome] = "unauthorized"
124
124
  head :forbidden
@@ -182,8 +182,8 @@ module SolidObjects
182
182
  end
183
183
  end
184
184
 
185
- # @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
186
- def component_frame(registration, snapshot, rendered)
185
+ # @rbs (registration: ComponentRegistration, snapshot: ActorSnapshot, rendered: untyped) -> String
186
+ def component_frame(registration:, snapshot:, rendered:)
187
187
  revision = "#{snapshot.instance_id}:#{snapshot.revision}"
188
188
  %(<turbo-frame id="#{registration.dom_id}" data-solid-objects-revision="#{revision}" data-solid-objects-refresh="#{registration.refresh_method}">#{rendered}</turbo-frame>).html_safe
189
189
  end
@@ -6,5 +6,13 @@ module SolidObjects
6
6
 
7
7
  belongs_to :message, class_name: "SolidObjects::Message"
8
8
  belongs_to :instance, class_name: "SolidObjects::Instance"
9
+
10
+ # @rbs () -> bool
11
+ def broadcasts_value?
12
+ return false if observable_name == PayloadBroadcast::REVISION_OBSERVABLE
13
+
14
+ actor_class = SolidObjects.registry.fetch(instance.actor_type)
15
+ actor_class.definition.broadcasts_observable_value?(observable_name)
16
+ end
9
17
  end
10
18
  end
@@ -20,7 +20,7 @@ module SolidObjects
20
20
 
21
21
  before_validation :supply_defaults
22
22
 
23
- validates :message_kind, inclusion: { in: %w[async sync internal] }
23
+ validates :delivery_mode, inclusion: { in: %w[async sync internal] }
24
24
 
25
25
  # @rbs () -> bool
26
26
  def ready?
@@ -15,7 +15,7 @@
15
15
  <% @dead_letters.each do |dead_letter| %>
16
16
  <tr>
17
17
  <td><%= dead_letter.actor_type %>(<%= dead_letter.actor_id %>)</td>
18
- <td><%= dead_letter.message_name %></td>
18
+ <td><%= dead_letter.operation %></td>
19
19
  <td><%= dead_letter.attempts %></td>
20
20
  <td><%= dead_letter.exception_class %>: <%= dead_letter.exception_message %></td>
21
21
  <td><%= dead_letter.last_failed_at %></td>
@@ -26,7 +26,7 @@
26
26
  <% @messages.each do |message| %>
27
27
  <tr>
28
28
  <td><%= message.sequence %></td>
29
- <td><%= message.message_name %></td>
29
+ <td><%= message.operation %></td>
30
30
  <td><%= message.attempt_count %></td>
31
31
  <td><%= message.completed_at %></td>
32
32
  </tr>
data/benchmark/support.rb CHANGED
@@ -98,13 +98,13 @@ module SolidObjectsBenchmark
98
98
  def enqueue
99
99
  reference = CounterActor.ref("enqueue")
100
100
  measure("enqueue #{count} messages") do
101
- count.times { reference.async(:increment) }
101
+ count.times { reference.async.increment }
102
102
  end
103
103
  end
104
104
 
105
105
  # @rbs () -> void
106
106
  def claim
107
- count.times { |index| CounterActor.ref("claim-#{index}").async(:increment) }
107
+ count.times { |index| CounterActor.ref("claim-#{index}").async.increment }
108
108
  process_registry = SolidObjects::ProcessRegistry.new
109
109
  owner_id = process_registry.register.id
110
110
  activation_manager = SolidObjects::ActivationManager.new(owner_id:)
@@ -137,7 +137,7 @@ module SolidObjectsBenchmark
137
137
 
138
138
  # @rbs () -> void
139
139
  def cold_actors
140
- count.times { |index| CounterActor.ref("cold-#{index}").async(:increment) }
140
+ count.times { |index| CounterActor.ref("cold-#{index}").async.increment }
141
141
  worker = SolidObjects::Worker.new
142
142
  measure("process #{count} cold actors") { drain(worker) }
143
143
  ensure
@@ -147,7 +147,7 @@ module SolidObjectsBenchmark
147
147
  # @rbs () -> void
148
148
  def hot_actor
149
149
  reference = CounterActor.ref("hot")
150
- count.times { reference.async(:increment) }
150
+ count.times { reference.async.increment }
151
151
  worker = SolidObjects::Worker.new
152
152
  measure("process #{count} messages for one hot actor") { drain(worker) }
153
153
  ensure
@@ -172,7 +172,7 @@ module SolidObjectsBenchmark
172
172
 
173
173
  count.times do |index|
174
174
  started_at = monotonic_now
175
- CounterActor.ref("sync-#{index}").sync(:count, timeout: 5)
175
+ CounterActor.ref("sync-#{index}").sync(timeout: 5).count
176
176
  samples << monotonic_now - started_at
177
177
  end
178
178
 
@@ -211,7 +211,7 @@ module SolidObjectsBenchmark
211
211
  # @rbs () -> void
212
212
  def activation_cache
213
213
  reference = CounterActor.ref("cache")
214
- count.times { reference.async(:increment) }
214
+ count.times { reference.async.increment }
215
215
  activations = 0
216
216
  subscriber = ActiveSupport::Notifications.subscribe("solid_objects.activation.started") do
217
217
  activations += 1
@@ -303,7 +303,7 @@ module SolidObjectsBenchmark
303
303
  # Runs the turn on the measuring thread, so nothing else can contribute.
304
304
  # @rbs () -> Integer
305
305
  def message_turn_query_count
306
- CounterActor.ref("queries").async(:increment)
306
+ CounterActor.ref("queries").async.increment
307
307
  worker = SolidObjects::Worker.new
308
308
  count_queries { worker.run_once }
309
309
  ensure
@@ -322,8 +322,8 @@ module SolidObjectsBenchmark
322
322
  reference = CounterActor.ref("sync-queries")
323
323
  worker = SolidObjects::Worker.new
324
324
  runner = Thread.new { worker.run }
325
- reference.sync(:increment)
326
- count_queries { reference.sync(:increment) }
325
+ reference.sync.increment
326
+ count_queries { reference.sync.increment }
327
327
  ensure
328
328
  worker&.request_shutdown
329
329
  runner&.join(5)
@@ -428,7 +428,7 @@ module SolidObjectsBenchmark
428
428
  def enqueue_round_robin
429
429
  actor_count = [ concurrency * 10, count ].min
430
430
  references = Array.new(actor_count) { |index| CounterActor.ref("actor-#{index}") }
431
- count.times { |index| references[index % actor_count].async(:increment) }
431
+ count.times { |index| references[index % actor_count].async.increment }
432
432
  end
433
433
 
434
434
  # @rbs (SolidObjects::Worker) -> Integer
@@ -0,0 +1,53 @@
1
+ # rbs_inline: enabled
2
+
3
+ class RenameMessageDispatchColumns < ActiveRecord::Migration[8.0]
4
+ # @rbs () -> void
5
+ def up
6
+ remove_check_constraint messages_table, name: "chk_so_messages_kind"
7
+ rename_column messages_table, :message_name, :operation
8
+ rename_column messages_table, :message_kind, :delivery_mode
9
+ rename_column reminders_table, :message_name, :operation
10
+ rename_column effects_table, :success_message_name, :success_operation
11
+ rename_column effects_table, :failure_message_name, :failure_operation
12
+ rename_column dead_letters_table, :message_name, :operation
13
+ add_check_constraint messages_table,
14
+ "delivery_mode IN ('async', 'sync', 'internal')",
15
+ name: "chk_so_messages_delivery_mode"
16
+ end
17
+
18
+ # @rbs () -> void
19
+ def down
20
+ remove_check_constraint messages_table, name: "chk_so_messages_delivery_mode"
21
+ rename_column messages_table, :operation, :message_name
22
+ rename_column messages_table, :delivery_mode, :message_kind
23
+ rename_column reminders_table, :operation, :message_name
24
+ rename_column effects_table, :success_operation, :success_message_name
25
+ rename_column effects_table, :failure_operation, :failure_message_name
26
+ rename_column dead_letters_table, :operation, :message_name
27
+ add_check_constraint messages_table,
28
+ "message_kind IN ('async', 'sync', 'internal')",
29
+ name: "chk_so_messages_kind"
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs () -> String
35
+ def messages_table
36
+ SolidObjects.table_name(:messages)
37
+ end
38
+
39
+ # @rbs () -> String
40
+ def reminders_table
41
+ SolidObjects.table_name(:reminders)
42
+ end
43
+
44
+ # @rbs () -> String
45
+ def effects_table
46
+ SolidObjects.table_name(:effects)
47
+ end
48
+
49
+ # @rbs () -> String
50
+ def dead_letters_table
51
+ SolidObjects.table_name(:dead_letters)
52
+ end
53
+ end
data/docs/architecture.md CHANGED
@@ -63,14 +63,15 @@ The registry maps a stable persisted actor type string to a Ruby actor class. Re
63
63
  A reference contains actor type and normalized actor ID. It is cheap,
64
64
  serializable as data, and does not imply an active Ruby object. Declared
65
65
  message, query, and attribute methods use synchronous caller-assisted
66
- invocation. `sync` provides the same behavior for a dynamic operation name,
67
- while `async` only durably enqueues a message and returns its reference.
66
+ invocation. `sync(...)` configures a caller-assisted invocation before its
67
+ operation is selected, while `async(...)` configures durable enqueue and
68
+ returns its message reference after the operation is selected.
68
69
  `destroy` is a reserved synchronous reference operation. Every path authorizes
69
70
  through the client.
70
71
 
71
72
  ### Client and mailbox
72
73
 
73
- The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates message names and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
74
+ The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
74
75
 
75
76
  Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.
76
77
 
@@ -112,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
112
113
 
113
114
  ### Effect worker
114
115
 
115
- An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
116
+ An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages and receive the effect ID, originally staged arguments, and result or error.
116
117
 
117
118
  ### Reminder scheduler
118
119
 
@@ -155,8 +156,9 @@ Public instance methods declared on the actor are messages. Declare helpers as
155
156
  private or protected. Messages and queries are exposed as methods on a
156
157
  reference through the same synchronous caller-assisted path. Returned state
157
158
  snapshots are deeply frozen. Use `async` for durable fire-and-forget delivery
158
- and `sync` for dynamic operation names. The explicit `message` DSL remains
159
- available for dynamic definitions.
159
+ and configured `sync` when a committed call needs a non-default timeout,
160
+ idempotency key, or authorization context. The explicit `message` DSL remains
161
+ available for declarations defined dynamically.
160
162
 
161
163
  `reference.snapshot` is the explicit unordered read path. It invokes query
162
164
  authorization, reads the most recently committed instance state without
@@ -167,7 +169,8 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
167
169
  `message` and `query` both execute as durable mailbox turns. A query may not
168
170
  mutate state. The executor detects query mutation and fails the message. An
169
171
  observable is a named projection of state used by server rendering and realtime
170
- updates; it is not independently persisted.
172
+ updates. Its durable broadcast row stores the projected value by default;
173
+ `broadcast: :invalidation` stores only an empty invalidation marker.
171
174
 
172
175
  Lifecycle hooks are deterministic local hooks:
173
176
 
@@ -183,7 +186,7 @@ with application Active Record writes prevented.
183
186
  Enqueue uses one transaction:
184
187
 
185
188
  1. Resolve actor class from the registry.
186
- 2. Validate authorization, message name, actor ID, arguments, and size.
189
+ 2. Validate authorization, operation, actor ID, arguments, and size.
187
190
  3. `INSERT ... ON CONFLICT` the actor instance if missing.
188
191
  4. Lock the instance row.
189
192
  5. Enforce the mailbox limit.
@@ -426,7 +429,7 @@ An effect handler receives JSON arguments plus an effect context. Outcome messag
426
429
  Actor code sends to another actor through a staged outbox:
427
430
 
428
431
  ```ruby
429
- send_to InventoryActor.ref("sku-123"), :reserve, order_id: id, quantity: 2
432
+ send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
430
433
  ```
431
434
 
432
435
  The source actor commit never waits for the target. The outbox worker allocates the target's sequence after source commit. There is no global order across actors.
@@ -436,7 +439,7 @@ The source actor commit never waits for the target. The outbox worker allocates
436
439
  A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
437
440
 
438
441
  ```ruby
439
- schedule :expire, at: 30.minutes.from_now, arguments: {}
442
+ schedule(at: 30.minutes.from_now).expire
440
443
  ```
441
444
 
442
445
  Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](../README.md#a-reminder-is-one-named-alarm-per-actor) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
@@ -467,7 +470,7 @@ The expected drift categories are actors with a lost alarm, missing actors for l
467
470
 
468
471
  Bulk repair updates to `solid_objects_instances` are forbidden. They bypass activation ownership and fencing and can overwrite a concurrently committed actor state. Direct reads are observational; writes go through actor messages.
469
472
 
470
- Large repairs use `async(..., available_at:)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
473
+ Large repairs use `async(available_at: ...).repair(...)` to spread work over an application-defined dispatch window. The durable message records the requested availability and ready membership drives the hot polling query. This prevents reconciliation from flooding mailboxes and starving normal traffic.
471
474
 
472
475
  ## Realtime integration
473
476
 
@@ -64,7 +64,7 @@ retain an empty arguments hash. This lets a policy authorize a projection such
64
64
  as one seat or player:
65
65
 
66
66
  ```ruby
67
- configuration.authorize_query = lambda do |actor_type:, actor_id:, message_name:, arguments:, authorization_context:|
67
+ configuration.authorize_query = lambda do |actor_type:, actor_id:, operation:, arguments:, authorization_context:|
68
68
  user = authorization_context
69
69
  player_id = arguments["player_id"]
70
70
 
@@ -126,7 +126,7 @@ SolidObjects.configure do |configuration|
126
126
  end
127
127
  ```
128
128
 
129
- The policy receives normalized actor type and ID strings, message name and
129
+ The policy receives normalized actor type and ID strings, operation and
130
130
  arguments where relevant, and the context supplied by the caller. Avoid
131
131
  authorizing from arguments alone; bind the actor identity to the authenticated
132
132
  principal and tenant.
@@ -37,9 +37,9 @@ scheduled reminder, unresolved outbox, or dead letter before deletion.
37
37
 
38
38
  ### `messages`
39
39
 
40
- Durable immutable invocation identity and arguments plus sequence, attempt
41
- count, request/idempotency IDs, result/error, requested availability, and
42
- execution timestamps. A terminal domain rejection stores a structured
40
+ Durable immutable invocation identity, selected operation, delivery mode, and
41
+ arguments plus sequence, attempt count, request/idempotency IDs, result/error,
42
+ requested availability, and execution timestamps. A terminal domain rejection stores a structured
43
43
  code/message/details document and rejection time. The table intentionally has
44
44
  no status column.
45
45
 
@@ -85,15 +85,15 @@ limit recurrence intervals, missed-work policies, and statuses.
85
85
  ### `effects`
86
86
 
87
87
  Transactional external-effect outbox. It stores stable effect UUID, arguments,
88
- optional actor outcome messages, attempts, claim ownership, result/error, and
88
+ optional actor outcome operations, attempts, claim ownership, result/error, and
89
89
  completion time. Claim ownership references the process registry.
90
90
  Status/availability/ID drives delivery; completion/ID drives cleanup.
91
91
 
92
92
  ### `broadcasts`
93
93
 
94
94
  Durable observable-change outbox. The unique message/observable key prevents
95
- duplicate rows for one actor turn. Rows contain the observable JSON value and
96
- message/instance references used to derive invalidation metadata, never
95
+ duplicate rows for one actor turn. Rows contain the observable JSON value, or
96
+ `{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
97
97
  personalized rendered HTML. Claim and delivery indexes support retries and
98
98
  cleanup.
99
99
 
data/docs/development.md CHANGED
@@ -56,7 +56,7 @@ Use `drain_solid_objects` to process actor, reminder, effect, callback, and
56
56
  broadcast work to a deterministic fixed point without arbitrary sleeps:
57
57
 
58
58
  ```ruby
59
- message = Counter.ref("test").async(:increment)
59
+ message = Counter.ref("test").async.increment
60
60
 
61
61
  assert_equal 1, drain_solid_objects
62
62
  assert_equal "completed", message.status
@@ -65,6 +65,18 @@ assert_equal "completed", message.status
65
65
  Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
66
66
  reminders pending.
67
67
 
68
+ Rails time travel does not move the database clock used by reminder claims. Run
69
+ future reminders against an explicit test instant instead of updating runtime
70
+ rows or sleeping:
71
+
72
+ ```ruby
73
+ assert_equal 1, run_due_reminders(now: 5.minutes.from_now)
74
+ assert_equal 1, drain_solid_objects(roles: [ :actors ])
75
+ ```
76
+
77
+ The explicit instant controls due selection and recurring schedule advancement.
78
+ Claim timestamps and stale-process recovery still use database time.
79
+
68
80
  `SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69
81
  boundaries. It deletes every actor-owned row itself rather than deleting actor
70
82
  instances and letting the database cascade remove the rest: SQLite has to be
@@ -71,11 +71,9 @@ Give every bootstrap call an idempotency key derived from the legacy record:
71
71
 
72
72
  ```ruby
73
73
  session.actor.async(
74
- :bootstrap,
75
- answers: legacy.answers,
76
74
  idempotency_key: "legacy-assessment:#{legacy.id}",
77
75
  available_at: jittered_time
78
- )
76
+ ).bootstrap(answers: legacy.answers)
79
77
  ```
80
78
 
81
79
  Spread large backfills over a dispatch window and monitor mailbox age,