solid_objects 0.10.3 → 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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +72 -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/configuration.rb +42 -0
  26. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  27. data/lib/solid_objects/dead_letter_manager.rb +4 -4
  28. data/lib/solid_objects/doctor.rb +8 -8
  29. data/lib/solid_objects/effect_executor.rb +22 -22
  30. data/lib/solid_objects/errors.rb +27 -27
  31. data/lib/solid_objects/executor.rb +40 -38
  32. data/lib/solid_objects/lease.rb +3 -3
  33. data/lib/solid_objects/mailbox.rb +34 -27
  34. data/lib/solid_objects/operation_dispatcher.rb +51 -0
  35. data/lib/solid_objects/payload_broadcast.rb +1 -1
  36. data/lib/solid_objects/reference.rb +60 -29
  37. data/lib/solid_objects/reminder_scheduler.rb +6 -6
  38. data/lib/solid_objects/supervisor.rb +50 -9
  39. data/lib/solid_objects/sync_diagnostics.rb +6 -6
  40. data/lib/solid_objects/turbo_stream_renderer.rb +13 -13
  41. data/lib/solid_objects/version.rb +1 -1
  42. data/lib/solid_objects.rb +1 -0
  43. data/sig/generated/controllers/solid_objects/components_controller.rbs +2 -2
  44. data/sig/generated/lib/solid_objects/actor.rbs +17 -17
  45. data/sig/generated/lib/solid_objects/actor_definition.rbs +4 -4
  46. data/sig/generated/lib/solid_objects/client.rbs +8 -8
  47. data/sig/generated/lib/solid_objects/component_registration.rbs +2 -2
  48. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +8 -8
  49. data/sig/generated/lib/solid_objects/configuration.rbs +32 -4
  50. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  51. data/sig/generated/lib/solid_objects/effect_executor.rbs +2 -2
  52. data/sig/generated/lib/solid_objects/errors.rbs +18 -18
  53. data/sig/generated/lib/solid_objects/executor.rbs +10 -10
  54. data/sig/generated/lib/solid_objects/lease.rbs +2 -2
  55. data/sig/generated/lib/solid_objects/mailbox.rbs +8 -8
  56. data/sig/generated/lib/solid_objects/operation_dispatcher.rbs +35 -0
  57. data/sig/generated/lib/solid_objects/reference.rbs +7 -4
  58. data/sig/generated/lib/solid_objects/supervisor.rbs +19 -2
  59. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +2 -2
  60. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +8 -8
  61. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36d165ff933a151ee0cc9fbff1ae834333f9591d1ae71068a0127d198dc4d5d8
4
- data.tar.gz: 1cfd279335dcf34f4cd2dd659412e04c5ac8d6ed98e1815cbd184bb38f67585f
3
+ metadata.gz: 15ee987ca3dcf3419f28902e2a0c18c8444c834964b743f3e7b99cd5f6dcde33
4
+ data.tar.gz: 45bf4d0449184c9fbf3718ce088f8541b68049e9458ee8802947fd74bef1fadb
5
5
  SHA512:
6
- metadata.gz: 9328e60fa9f4e08f17cfe4404fe117af3a2c8efef53ba379b5c824d73a71063ae7f3b8e438b177ec4fcacb1ca31fdb86686212a6ac6d0f972e4f337233d6dea2
7
- data.tar.gz: b39f02c65284721d06c02eee9bbd678eb808770136847715f83525e3e43fe461f89e9837c0811c6730b50584b4c74c572dcf8e3de1cbbdae7fcfbf2dbc7f6708
6
+ metadata.gz: 6b5b29a7f3052c1a04a012f348a9f8f1054500056073b34c8b2a6680f7d84db195efbe0d5d68809b33ba7bed04af1c141777a27801e6049df0863311cd948a25
7
+ data.tar.gz: 93e7bdeb0ee3a1a0c4f212f5da40c25d61bca88b1ea2dc87efb4b09d199068b5374452beb991baec05b573ac3e0109ca86dde37463a7cf5f28d823f02969767a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
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
+
26
+ ## 0.11.0 - 2026-08-11
27
+
28
+ - Add `SolidObjects.configuration.register_component`. An extension gem can now
29
+ register a long running component, and the supervisor runs it beside the
30
+ workers, the effect executors, the broadcast executors, and the reminder
31
+ schedulers. The component joins the same supervision, replacement, and
32
+ shutdown timeout. Without it, an extension has to ask an operator to run and
33
+ monitor a second process for work that belongs to the same runtime. A
34
+ registered component must answer `run`, `request_shutdown`, `stopped?`, and
35
+ `stop`. The supervisor checks that contract when it builds the component and
36
+ raises `ArgumentError` when a method is missing. Registration never calls the
37
+ block, so a component may need a database connection that the application
38
+ does not have while it boots.
39
+
40
+ - Stop the components already built when a later one fails. The supervisor
41
+ builds its components one after another, so a factory that raised, or a
42
+ component that failed the contract check, left the earlier ones constructed
43
+ and unreachable while they still held whatever their constructors took. Each
44
+ one now receives `stop`, and a failure inside that cleanup never replaces the
45
+ failure that caused it.
46
+
47
+ - Replace a crashed component through the builder that made it. The supervisor
48
+ called `component.class.new`, which discards every constructor argument, so a
49
+ component built with arguments returned with its defaults after a crash. Each
50
+ component now keeps its builder. The built in components take no constructor
51
+ arguments, so their behavior does not change.
52
+
3
53
  ## 0.10.3 - 2026-08-11
4
54
 
5
55
  - Delete every actor-owned row in `SolidObjects::TestHelper#reset_actors!`. It
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
 
@@ -458,6 +458,39 @@ Deploy and monitor that process before enabling any feature marked as requiring
458
458
  a runtime role. A missing worker never makes a durable `async` message
459
459
  disappear, but it leaves the message pending indefinitely.
460
460
 
461
+ ### Running an extension in the same process
462
+
463
+ An extension gem can register its own long-running component, and
464
+ `solid_objects start` runs it beside the built-in roles. The component joins the
465
+ same supervision, the same replacement after a crash, and the same shutdown
466
+ timeout, so an operator deploys and monitors one process instead of two:
467
+
468
+ ```ruby
469
+ SolidObjects.configure do |configuration|
470
+ configuration.register_component { MyExtension::FlushEngine.new }
471
+ end
472
+ ```
473
+
474
+ Pass `count:` for more than one instance. The block runs once for each instance,
475
+ and again when the supervisor replaces a crashed one, so no two components share
476
+ an object.
477
+
478
+ A registered component answers four methods, the contract the built-in roles
479
+ already keep:
480
+
481
+ | Method | Purpose |
482
+ | --- | --- |
483
+ | `run` | Runs the loop. The supervisor calls it in its own thread |
484
+ | `request_shutdown` | Asks the loop to finish. It must make `run` return |
485
+ | `stopped?` | Reports whether the component already finished |
486
+ | `stop` | Forces cleanup when the shutdown timeout expires first |
487
+
488
+ The supervisor checks that contract when it builds the component, and a missing
489
+ method raises `ArgumentError` as the supervisor starts, rather than hanging a
490
+ shutdown later. Registration itself never calls the block, so a component is
491
+ free to need a database connection that the application does not have while it
492
+ boots.
493
+
461
494
  ## Defining an actor
462
495
 
463
496
  The Durable Object class becomes an ordinary Ruby class:
@@ -503,7 +536,7 @@ cart.add_item(product_id: "shirt-123", quantity: 2)
503
536
  items = cart.items
504
537
  ```
505
538
 
506
- 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
507
540
  without waiting; that call returns a `SolidObjects::MessageReference`. `items`
508
541
  is a deeply frozen JSON snapshot, so mutating it cannot bypass the actor
509
542
  mailbox. State changes must go through public actor methods or explicit
@@ -605,26 +638,34 @@ Use `async` for durable fire-and-forget work. It returns a
605
638
 
606
639
  ```ruby
607
640
  message = order.async(
608
- :submit,
609
- idempotency_key: "submit-order-123"
610
- )
641
+ idempotency_key: "submit-order-123",
642
+ authorization_context: Current.user
643
+ ).submit
611
644
  ```
612
645
 
613
646
  Use `available_at:` to spread bulk work or delay one message:
614
647
 
615
648
  ```ruby
616
- order.async(:evaluate, available_at: 10.minutes.from_now)
649
+ order.async(available_at: 10.minutes.from_now).evaluate
617
650
  ```
618
651
 
619
652
  ### `sync`
620
653
 
621
- Use explicit `sync` when the operation name is dynamic or collides with a
622
- reference method:
654
+ Use explicit `sync` when the invocation needs a timeout, idempotency key, or
655
+ authorization context different from the defaults:
623
656
 
624
657
  ```ruby
625
- status = order.sync(:status, timeout: 5.seconds)
658
+ status = order.sync(
659
+ timeout: 5.seconds,
660
+ authorization_context: Current.user
661
+ ).status
626
662
  ```
627
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
+
628
669
  Direct calls and `sync` use the same caller-assisted execution path. A healthy
629
670
  actor normally needs no worker round trip, making this path suitable for HTTP
630
671
  and MCP request/response boundaries when the handler itself fits the
@@ -644,7 +685,7 @@ recover its eventual result through the durable message identity:
644
685
 
645
686
  ```ruby
646
687
  begin
647
- order.submit(timeout: 250.milliseconds)
688
+ order.sync(timeout: 250.milliseconds).submit
648
689
  rescue SolidObjects::SyncTimeout => error
649
690
  result = error.message_reference.wait(
650
691
  timeout: 5.seconds,
@@ -667,6 +708,17 @@ Actor code cannot use direct calls or `sync` on another actor; synchronous
667
708
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
668
709
  result message.
669
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
+
670
722
  ### Domain rejection
671
723
 
672
724
  Reject invalid input without retrying or creating a dead letter:
@@ -798,7 +850,11 @@ API. One-shot and recurring alarms are actor-owned database records:
798
850
 
799
851
  ```ruby
800
852
  def schedule_evaluation
801
- 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:)
802
858
  end
803
859
  ```
804
860
 
@@ -819,7 +875,7 @@ once. It also means this is a data-loss bug:
819
875
  # Wrong. Every entry overwrites the previous entry's alarm.
820
876
  def add(entry:)
821
877
  self.entries = entries + [ entry ]
822
- schedule :deliver, at: entry.fetch("wait_until"), arguments: {}
878
+ schedule(at: entry.fetch("wait_until")).deliver
823
879
  end
824
880
  ```
825
881
 
@@ -849,7 +905,7 @@ def arm_next
849
905
  earliest = entries.first
850
906
  return unless earliest
851
907
 
852
- schedule :deliver, at: Time.at(earliest.fetch("wait_until")), arguments: {}
908
+ schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
853
909
  end
854
910
  ```
855
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