solid_objects 0.11.0 → 0.12.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +39 -16
  4. data/app/controllers/solid_objects/components_controller.rb +4 -4
  5. data/app/models/solid_objects/message.rb +1 -1
  6. data/app/views/solid_objects/dead_letters/index.html.erb +1 -1
  7. data/app/views/solid_objects/instances/show.html.erb +1 -1
  8. data/benchmark/support.rb +10 -10
  9. data/db/migrate/20260813000000_rename_message_dispatch_columns.rb +53 -0
  10. data/docs/architecture.md +11 -9
  11. data/docs/authorization.md +2 -2
  12. data/docs/database-schema.md +4 -4
  13. data/docs/development.md +1 -1
  14. data/docs/migrating-existing-state.md +1 -3
  15. data/docs/roadmap.md +1 -1
  16. data/lib/solid_objects/actor.rb +43 -32
  17. data/lib/solid_objects/actor_channel.rb +3 -3
  18. data/lib/solid_objects/actor_definition.rb +9 -6
  19. data/lib/solid_objects/actor_view.rb +1 -1
  20. data/lib/solid_objects/application_write_guard.rb +1 -1
  21. data/lib/solid_objects/client.rb +52 -52
  22. data/lib/solid_objects/component_registration.rb +2 -2
  23. data/lib/solid_objects/component_renderer.rb +1 -1
  24. data/lib/solid_objects/component_subscriptions.rb +21 -21
  25. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  26. data/lib/solid_objects/dead_letter_manager.rb +4 -4
  27. data/lib/solid_objects/doctor.rb +8 -8
  28. data/lib/solid_objects/effect_executor.rb +22 -22
  29. data/lib/solid_objects/errors.rb +27 -27
  30. data/lib/solid_objects/executor.rb +40 -38
  31. data/lib/solid_objects/lease.rb +3 -3
  32. data/lib/solid_objects/mailbox.rb +34 -27
  33. data/lib/solid_objects/operation_dispatcher.rb +51 -0
  34. data/lib/solid_objects/payload_broadcast.rb +1 -1
  35. data/lib/solid_objects/reference.rb +60 -29
  36. data/lib/solid_objects/reminder_scheduler.rb +6 -6
  37. data/lib/solid_objects/sync_diagnostics.rb +6 -6
  38. data/lib/solid_objects/turbo_stream_renderer.rb +13 -13
  39. data/lib/solid_objects/version.rb +1 -1
  40. data/lib/solid_objects.rb +1 -0
  41. data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
  42. data/sig/generated/lib/solid_objects/actor.rbs +17 -17
  43. data/sig/generated/lib/solid_objects/actor_definition.rbs +4 -4
  44. data/sig/generated/lib/solid_objects/client.rbs +8 -8
  45. data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
  46. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
  47. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  48. data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
  49. data/sig/generated/lib/solid_objects/errors.rbs +18 -18
  50. data/sig/generated/lib/solid_objects/executor.rbs +10 -10
  51. data/sig/generated/lib/solid_objects/lease.rbs +2 -2
  52. data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
  53. data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
  54. data/sig/generated/lib/solid_objects/reference.rbs +7 -4
  55. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
  56. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
  57. 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: 15ee987ca3dcf3419f28902e2a0c18c8444c834964b743f3e7b99cd5f6dcde33
4
+ data.tar.gz: 45bf4d0449184c9fbf3718ce088f8541b68049e9458ee8802947fd74bef1fadb
5
5
  SHA512:
6
- metadata.gz: edd288ba3cc57f8ac7c241eda9e3e320385f26071a5dc1e11e3676af1106dc1ddadddfa520ff08f4ce61a0b2f73c23d1a2418199c8f6f98ebe85fe2b51b07426
7
- data.tar.gz: 38e5561275048f00dcbd5e363b0d3da6a00242348094776c63da44df7e2ca694292e7bc0546d44ac5da7a8883eb70d3e237d7495c888d6d3e2dd9d286d1754f0
6
+ metadata.gz: 6b5b29a7f3052c1a04a012f348a9f8f1054500056073b34c8b2a6680f7d84db195efbe0d5d68809b33ba7bed04af1c141777a27801e6049df0863311cd948a25
7
+ data.tar.gz: 93e7bdeb0ee3a1a0c4f212f5da40c25d61bca88b1ea2dc87efb4b09d199068b5374452beb991baec05b573ac3e0109ca86dde37463a7cf5f28d823f02969767a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 - 2026-08-13
4
+
5
+ - Replace positional actor dispatch with fluent operation selection. Direct
6
+ committed calls remain `account.disable(...)`; configured committed calls
7
+ use `account.sync(timeout: ...).status`; asynchronous calls use
8
+ `account.async(...).disable(...)`; actor outbox delivery uses
9
+ `send_to(account, ...).disable(...)`; and reminders use
10
+ `schedule(at: ...).evaluate(...)`. Delivery and reminder options are now
11
+ unambiguously separate from actor message arguments. The former positional
12
+ `async`, `sync`, `send_to`, and `schedule` forms are removed.
13
+ - Validate fluent operations before enqueueing or staging them. Direct and
14
+ configured synchronous calls accept messages and queries, while `async`,
15
+ `send_to`, and `schedule` accept public actor messages only.
16
+ - Use operation terminology throughout invocation persistence and diagnostics.
17
+ The new migration renames stored message names to `operation`, message kind
18
+ to `delivery_mode`, and effect callback message names to operation names.
19
+ - Make internal methods with more than two arguments keyword-only so dispatch,
20
+ persistence, component refresh, and diagnostics call sites name every value.
21
+ - Keep SQLite reconnect failures inside a synchronous lock deadline. Active
22
+ Record can reconnect after a lock error while the lock is still held, and
23
+ configuring WAL then raises `SQLite3::CantOpenException`; it now retries
24
+ within the original deadline and surfaces the documented sync timeout.
25
+
3
26
  ## 0.11.0 - 2026-08-11
4
27
 
5
28
  - 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
 
@@ -536,7 +536,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
536
536
  items = cart.items
537
537
  ```
538
538
 
539
- Use `cart.async(:add_item, product_id: "shirt-123", quantity: 2)` to enqueue
539
+ Use `cart.async.add_item(product_id: "shirt-123", quantity: 2)` to enqueue
540
540
  without waiting; that call returns a `SolidObjects::MessageReference`. `items`
541
541
  is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
542
542
  mailbox. State changes must go through public actor methods or explicit
@@ -638,26 +638,34 @@ Use `async` for durable fire-and-forget work. It returns a
638
638
 
639
639
  ```ruby
640
640
  message = order.async(
641
- :submit,
642
- idempotency_key: "submit-order-123"
643
- )
641
+ idempotency_key: "submit-order-123",
642
+ authorization_context: Current.user
643
+ ).submit
644
644
  ```
645
645
 
646
646
  Use `available_at:` to spread bulk work or delay one message:
647
647
 
648
648
  ```ruby
649
- order.async(:evaluate, available_at: 10.minutes.from_now)
649
+ order.async(available_at: 10.minutes.from_now).evaluate
650
650
  ```
651
651
 
652
652
  ### `sync`
653
653
 
654
- Use explicit `sync` when the operation name is dynamic or collides with a
655
- reference method:
654
+ Use explicit `sync` when the invocation needs a timeout, idempotency key, or
655
+ authorization context different from the defaults:
656
656
 
657
657
  ```ruby
658
- status = order.sync(:status, timeout: 5.seconds)
658
+ status = order.sync(
659
+ timeout: 5.seconds,
660
+ authorization_context: Current.user
661
+ ).status
659
662
  ```
660
663
 
664
+ Delivery configuration belongs on `async(...)` or `sync(...)` before the
665
+ operation. Keywords on the final method call are always actor message
666
+ arguments, so `order.sync(timeout: 5.seconds).record(timeout: "payload")`
667
+ keeps the invocation timeout separate from the payload value.
668
+
661
669
  Direct calls and `sync` use the same caller-assisted execution path. A healthy
662
670
  actor normally needs no worker round trip, making this path suitable for HTTP
663
671
  and MCP request/response boundaries when the handler itself fits the
@@ -677,7 +685,7 @@ recover its eventual result through the durable message identity:
677
685
 
678
686
  ```ruby
679
687
  begin
680
- order.submit(timeout: 250.milliseconds)
688
+ order.sync(timeout: 250.milliseconds).submit
681
689
  rescue SolidObjects::SyncTimeout => error
682
690
  result = error.message_reference.wait(
683
691
  timeout: 5.seconds,
@@ -700,6 +708,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
700
708
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
701
709
  result message.
702
710
 
711
+ ```ruby
712
+ send_to(
713
+ audit_log,
714
+ available_at: 5.minutes.from_now,
715
+ idempotency_key: event_id
716
+ ).record(event_id:, event_name: "account_disabled")
717
+ ```
718
+
719
+ Actor-to-actor delivery is staged with the current turn, returns `nil`, and is
720
+ discarded if that turn does not commit. It accepts messages, not queries.
721
+
703
722
  ### Domain rejection
704
723
 
705
724
  Reject invalid input without retrying or creating a dead letter:
@@ -831,7 +850,11 @@ API. One-shot and recurring alarms are actor-owned database records:
831
850
 
832
851
  ```ruby
833
852
  def schedule_evaluation
834
- schedule :evaluate, at: 1.hour.from_now, every: 1.hour, missed: :latest
853
+ schedule(
854
+ at: 1.hour.from_now,
855
+ every: 1.hour,
856
+ missed: :latest
857
+ ).evaluate(account_id:)
835
858
  end
836
859
  ```
837
860
 
@@ -852,7 +875,7 @@ once. It also means this is a data-loss bug:
852
875
  # Wrong. Every entry overwrites the previous entry's alarm.
853
876
  def add(entry:)
854
877
  self.entries = entries + [ entry ]
855
- schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
878
+ schedule(at: entry.fetch("wait_until")).deliver
856
879
  end
857
880
  ```
858
881
 
@@ -882,7 +905,7 @@ def arm_next
882
905
  earliest = entries.first
883
906
  return unless earliest
884
907
 
885
- schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
908
+ schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
886
909
  end
887
910
  ```
888
911
 
@@ -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
@@ -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
 
@@ -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
@@ -183,7 +185,7 @@ with application Active Record writes prevented.
183
185
  Enqueue uses one transaction:
184
186
 
185
187
  1. Resolve actor class from the registry.
186
- 2. Validate authorization, message name, actor ID, arguments, and size.
188
+ 2. Validate authorization, operation, actor ID, arguments, and size.
187
189
  3. `INSERT ... ON CONFLICT` the actor instance if missing.
188
190
  4. Lock the instance row.
189
191
  5. Enforce the mailbox limit.
@@ -426,7 +428,7 @@ An effect handler receives JSON arguments plus an effect context. Outcome messag
426
428
  Actor code sends to another actor through a staged outbox:
427
429
 
428
430
  ```ruby
429
- send_to InventoryActor.ref("sku-123"), :reserve, order_id: id, quantity: 2
431
+ send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
430
432
  ```
431
433
 
432
434
  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 +438,7 @@ The source actor commit never waits for the target. The outbox worker allocates
436
438
  A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
437
439
 
438
440
  ```ruby
439
- schedule :expire, at: 30.minutes.from_now, arguments: {}
441
+ schedule(at: 30.minutes.from_now).expire
440
442
  ```
441
443
 
442
444
  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 +469,7 @@ The expected drift categories are actors with a lost alarm, missing actors for l
467
469
 
468
470
  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
471
 
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.
472
+ 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
473
 
472
474
  ## Realtime integration
473
475
 
@@ -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,7 +85,7 @@ 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
 
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
@@ -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,
data/docs/roadmap.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  - Rails engine, install generator, migration, and CLI
6
6
  - Explicit actor registry, references, JSON state, and state migrations
7
- - Direct synchronous RPC, explicit `sync`, and durable `async`
7
+ - Fluent direct synchronous RPC, configured `sync`, and durable `async`
8
8
  - Durable message history plus ready/claimed membership tables
9
9
  - Concurrent sequence allocation and actor creation
10
10
  - Activation leases, renewal, unique activation tokens, generations, and
@@ -2,10 +2,10 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Actor
5
- EffectIntent = Data.define(:name, :arguments, :success_message_name, :failure_message_name)
5
+ EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
6
6
  CommitActionIntent = Data.define(:name, :arguments)
7
7
  ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
8
- OutboundMessageIntent = Data.define(:actor_type, :actor_id, :message_name, :arguments, :available_at, :idempotency_key)
8
+ OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
9
9
 
10
10
  class << self
11
11
  # @rbs (Class) -> void
@@ -72,7 +72,7 @@ module SolidObjects
72
72
 
73
73
  # @rbs (from: Integer, to: Integer) { (Hash[String, untyped]) -> Hash[String, untyped] } -> ActorDefinition::StateMigration
74
74
  def migrate_state(from:, to:, &block)
75
- definition.add_state_migration(from, to, block)
75
+ definition.add_state_migration(from:, to:, block:)
76
76
  end
77
77
 
78
78
  # @rbs () { () -> untyped } -> Proc
@@ -177,8 +177,8 @@ module SolidObjects
177
177
  EffectIntent.new(
178
178
  name: name.to_s,
179
179
  arguments: Serialization.dump(arguments),
180
- success_message_name: on_success&.to_s,
181
- failure_message_name: on_failure&.to_s
180
+ success_operation: on_success&.to_s,
181
+ failure_operation: on_failure&.to_s
182
182
  ).tap do |intent|
183
183
  effect_intents << intent
184
184
  end
@@ -196,8 +196,8 @@ module SolidObjects
196
196
  nil
197
197
  end
198
198
 
199
- # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
200
- def schedule(name, at:, every: nil, missed: :latest, arguments: {})
199
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
200
+ def schedule(at:, every: nil, missed: :latest)
201
201
  interval_seconds = every&.to_f
202
202
  if interval_seconds && !interval_seconds.positive?
203
203
  raise ArgumentError, "reminder interval must be positive"
@@ -207,31 +207,42 @@ module SolidObjects
207
207
  raise ArgumentError, "missed reminder policy must be all or latest"
208
208
  end
209
209
 
210
- ReminderIntent.new(
211
- name: name.to_s,
212
- at:,
213
- arguments: Serialization.dump(arguments),
214
- interval_seconds:,
215
- missed_policy:
216
- ).tap do |intent|
217
- reminder_intents << intent
210
+ OperationDispatcher.new(
211
+ actor_type: self.class.actor_type,
212
+ handlers: self.class.definition.messages
213
+ ) do |operation, arguments|
214
+ ReminderIntent.new(
215
+ name: operation.to_s,
216
+ at:,
217
+ arguments: Serialization.dump(arguments),
218
+ interval_seconds:,
219
+ missed_policy:
220
+ ).tap do |intent|
221
+ reminder_intents << intent
222
+ end
223
+ nil
218
224
  end
219
- nil
220
225
  end
221
226
 
222
- # @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> nil
223
- def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
224
- stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
225
- nil
227
+ # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
228
+ def send_to(reference, available_at: nil, idempotency_key: nil)
229
+ actor_class = SolidObjects.registry.fetch(reference.actor_type)
230
+ OperationDispatcher.new(
231
+ actor_type: reference.actor_type,
232
+ handlers: actor_class.definition.messages
233
+ ) do |operation, arguments|
234
+ stage_outbound_message(reference:, operation:, arguments:, available_at:, idempotency_key:)
235
+ nil
236
+ end
226
237
  end
227
238
 
228
239
  # @rbs (Symbol | String, Hash[String, untyped]) -> untyped
229
- def invoke(message_name, arguments)
230
- handler = self.class.definition.messages[message_name.to_sym] ||
231
- self.class.definition.queries[message_name.to_sym]
232
- raise UnknownMessage, "unknown message #{message_name.inspect} for #{self.class.actor_type}" unless handler
240
+ def invoke(operation, arguments)
241
+ handler = self.class.definition.messages[operation.to_sym] ||
242
+ self.class.definition.queries[operation.to_sym]
243
+ raise UnknownMessage, "unknown operation #{operation.inspect} for #{self.class.actor_type}" unless handler
233
244
 
234
- guard_application_writes(message_name.to_s) do
245
+ guard_application_writes(operation.to_s) do
235
246
  instance_exec(**keyword_arguments(arguments), &handler.block)
236
247
  end
237
248
  end
@@ -270,12 +281,12 @@ module SolidObjects
270
281
  end
271
282
  end
272
283
 
273
- # @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
274
- def stage_outbound_message(reference, message_name, arguments, available_at: nil, idempotency_key: nil)
284
+ # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], ?available_at: Time?, idempotency_key: String?) -> OutboundMessageIntent
285
+ def stage_outbound_message(reference:, operation:, arguments:, available_at: nil, idempotency_key: nil)
275
286
  OutboundMessageIntent.new(
276
287
  actor_type: reference.actor_type,
277
288
  actor_id: reference.actor_id,
278
- message_name: message_name.to_s,
289
+ operation: operation.to_s,
279
290
  arguments: Serialization.dump(arguments),
280
291
  available_at:,
281
292
  idempotency_key:
@@ -333,11 +344,11 @@ module SolidObjects
333
344
  end
334
345
 
335
346
  # @rbs (Symbol | String?) -> void
336
- def validate_effect_callback!(message_name)
337
- return unless message_name
338
- return if self.class.definition.messages.key?(message_name.to_sym)
347
+ def validate_effect_callback!(operation)
348
+ return unless operation
349
+ return if self.class.definition.messages.key?(operation.to_sym)
339
350
 
340
- raise UnknownMessage, "unknown effect callback message #{message_name.inspect}"
351
+ raise UnknownMessage, "unknown effect callback operation #{operation.inspect}"
341
352
  end
342
353
  end
343
354
  end