solid_objects 0.13.1 → 0.13.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
4
- data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
3
+ metadata.gz: 9045039767fa2e9ff5a4b5cd2bceb97ca5bc8ea0101b41aaac8fcefcf8a694ef
4
+ data.tar.gz: efed76c2524be2f7a9591a631c56355f95edaf39332cea846fb895363b8d8d68
5
5
  SHA512:
6
- metadata.gz: b1b5284ef616f306bea2136d7becf4a3c8e4aa2adbcc24eb5da08c3f33e85f34551f687ff7133cd3781abba79d4f0dac919657d9150bfe3ebd021417542d505b
7
- data.tar.gz: 6971c0ee78d9eab66776a5ba6d60dcc403d3346c57cffca795797f50048fc419b6473d3d620c4d025238916757f52ed79ed1ec153feaa3dda84744d740b08045
6
+ metadata.gz: 691972f84f5304cbe3f3be42b5889697b179f1eca38e55d76f87827e0ffc5c095ec97c0d591fa58680829920c807bde08619dc725add17be7f1215439775f064
7
+ data.tar.gz: 68bff7f63f6557e50a6262e22b33fd7633372ec77a33e35b37f95d074498b66b98b01527776b18acbe2ae6527133c6bf7588e50f98e501a0c5636101dac6f45d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.3 - 2026-08-18
4
+
5
+ - Stop loading `ActiveRecord::Base` when the gem is required. The engine now
6
+ loads `SolidObjects::Record` from an `ActiveSupport.on_load(:active_record)`
7
+ hook, so a host application keeps the normal timing of its own
8
+ `on_load(:active_record)` and `on_load(:active_record_encryption)` hooks. An
9
+ application that assigns its Active Record encryption keys in
10
+ `config/initializers` no longer loses them.
11
+ - Apply the configured `connects_to` in the record class body, so the
12
+ connection follows the class through a development reload.
13
+ - Lower the supported Rails floor from 8.0 to 7.1. The gem dependencies, the
14
+ bundled migrations, and the compatibility CI matrix now cover Rails 7.1, 7.2,
15
+ 8.0, and 8.1. The migrations declare `ActiveRecord::Migration[7.1]`, which
16
+ builds the same schema as `[8.0]` because the compatibility layer between the
17
+ two only changes `remove_foreign_key`, which no Solid Objects migration calls.
18
+ Rails 7.0 stays out of range: its SQLite adapter requires `sqlite3 ~> 1.4`,
19
+ and the busy-handler control this gem needs arrived in `sqlite3` 2.x.
20
+
21
+ ## 0.13.2 - 2026-08-17
22
+
23
+ - Accept a `key:` on `schedule`, naming a reminder for the item it is waiting
24
+ on rather than for its operation, so one actor can hold an alarm per queued
25
+ item. Scheduling the same key again moves that item's alarm and leaves the
26
+ others alone. Without a key the name is still the operation, so existing
27
+ reminders keep their names and their coalescing behaviour. A reminder
28
+ operation may no longer hold the colon that separates a key, which keeps
29
+ keyed and unkeyed names disjoint, and the length is checked on the composed
30
+ name rather than the key alone.
31
+ - Add an authorized `SolidObjects.administration.processes` query for
32
+ inspecting live and stale process rows through the runtime database adapter.
33
+ - Document rolling-deployment overlap as a reason for the polling-only warning.
34
+
3
35
  ## 0.13.1 - 2026-08-16
4
36
 
5
37
  - Back idle actor, effect, reminder, and broadcast polling off exponentially
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
4
4
 
5
- **Cloudflare Durable Objects, ported to Rails.**
5
+ **Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
6
6
 
7
7
  Solid Objects brings the Durable Objects programming model—addressable objects,
8
8
  durable state, serialized turns, alarms, and live clients—to ordinary Rails
@@ -341,7 +341,8 @@ mount SolidObjects::Engine => "/solid_objects"
341
341
 
342
342
  ## Installation
343
343
 
344
- Solid Objects requires Ruby 3.3 or newer and Rails 8.0 or newer.
344
+ Solid Objects requires Ruby 3.3 or newer and Rails 7.1 or newer. CI runs the
345
+ suite against Rails 7.1, 7.2, 8.0, and 8.1.
345
346
 
346
347
  Add the gem, install its initializer and migration, then migrate:
347
348
 
@@ -891,7 +892,7 @@ database enforces this with a unique index on `(instance_id, name)`.
891
892
 
892
893
  This is the same model as Orleans reminders and Durable Objects alarms, and it
893
894
  is what makes a reminder safe to re-arm from a handler that may run more than
894
- once. It also means this is a data-loss bug:
895
+ once. Without a key the name is the operation, so this is a data-loss bug:
895
896
 
896
897
  ```ruby
897
898
  # Wrong. Every entry overwrites the previous entry's alarm.
@@ -904,8 +905,38 @@ end
904
905
  Two entries leave one reminder. The earlier wake-up never happens, nothing
905
906
  raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
906
907
 
907
- Arm one alarm for the earliest item instead, and let the handler drain
908
- everything now due before arming the next:
908
+ ### An alarm per item, with `key:`
909
+
910
+ Pass `key:` when an actor is waiting on several things at once. The key is your
911
+ own identifier for the item, and it names that item's alarm, so each item gets
912
+ one:
913
+
914
+ ```ruby
915
+ def add(entry:)
916
+ self.entries = entries + [ entry ]
917
+ schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
918
+ end
919
+ ```
920
+
921
+ Two entries now leave two reminders. Scheduling the same key again moves that
922
+ item's alarm and leaves the others alone, which is what makes a keyed reminder
923
+ as safe to re-arm as an unkeyed one. The operation still decides which handler
924
+ runs; the key only decides which alarm is which.
925
+
926
+ A key must be non-empty, and the name it becomes must fit the 191-character
927
+ column, which is checked on the composed name rather than the key alone so a
928
+ long operation and a short key are caught too.
929
+
930
+ The key is separated from the operation by a colon, so an operation may not hold
931
+ one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
932
+ one name, and the second would silently take the first one's alarm. A key may
933
+ hold colons of its own, because the operation before the first one cannot.
934
+
935
+ ### One alarm for a whole queue
936
+
937
+ A key per item is not always what you want. An actor that only ever needs to
938
+ know "what is next" can keep one alarm and let the handler drain everything now
939
+ due before arming the next:
909
940
 
910
941
  ```ruby
911
942
  def add(entry:)
@@ -931,10 +962,10 @@ def arm_next
931
962
  end
932
963
  ```
933
964
 
934
- `deliver` drains every due item rather than one, so a single alarm serves a
935
- whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
936
- distinct reminder name only when you genuinely need two independent alarms on
937
- one actor, such as `:deliver` and `:sweep`.
965
+ That costs one reminder row instead of one per item, and a coalesced occurrence
966
+ cannot strand an entry because the handler drains by time rather than by alarm.
967
+ Prefer it when the queue is large and the items are interchangeable; prefer
968
+ `key:` when an item needs its own alarm that can be moved on its own.
938
969
 
939
970
  Solid Objects has no `unschedule`. A reminder stops when its handler does not
940
971
  re-arm it, and destroying an actor removes its reminders.
@@ -13,5 +13,7 @@ module SolidObjects
13
13
  connects_to(**connection_configuration)
14
14
  end
15
15
  end
16
+
17
+ configure_connection
16
18
  end
17
19
  end
@@ -1,6 +1,6 @@
1
1
  # rbs_inline: enabled
2
2
 
3
- class CreateSolidObjectsTables < ActiveRecord::Migration[8.0]
3
+ class CreateSolidObjectsTables < ActiveRecord::Migration[7.1]
4
4
  # @rbs () -> void
5
5
  def change
6
6
  create_processes
@@ -1,6 +1,6 @@
1
1
  # rbs_inline: enabled
2
2
 
3
- class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0]
3
+ class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[7.1]
4
4
  # @rbs () -> void
5
5
  def change
6
6
  add_column SolidObjects.table_name(:instances),
@@ -1,6 +1,6 @@
1
1
  # rbs_inline: enabled
2
2
 
3
- class RenameMessageDispatchColumns < ActiveRecord::Migration[8.0]
3
+ class RenameMessageDispatchColumns < ActiveRecord::Migration[7.1]
4
4
  # @rbs () -> void
5
5
  def up
6
6
  remove_check_constraint messages_table, name: "chk_so_messages_kind"
data/docs/development.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Requirements
4
4
 
5
5
  - Ruby 3.3 or newer
6
- - Rails 8.0 or newer
6
+ - Rails 7.1 or newer
7
7
  - SQLite 3.35+, PostgreSQL 14+, and MySQL 8.0/InnoDB for the full matrix
8
8
 
9
9
  Install dependencies:
data/docs/operations.md CHANGED
@@ -98,6 +98,14 @@ adapter is configured, the runtime logs
98
98
  need prompt delivery. Without one, newly committed work can wait up to the
99
99
  current idle polling interval.
100
100
 
101
+ The warning excludes process rows with the current hostname and PID. It can
102
+ therefore appear during a rolling deployment or restart overlap when an older
103
+ and newer process briefly share the database. A process that stopped without
104
+ graceful cleanup remains live until its heartbeat exceeds
105
+ `process_alive_threshold`; inspect `SolidObjects.administration.processes` to
106
+ distinguish a live overlap from a stale row without opening a second SQLite
107
+ connection.
108
+
101
109
  Each role exposes `current_polling_interval`.
102
110
  `solid_objects.polling.interval_changed` reports the role, reason, previous
103
111
  interval, and current interval. The polling-only warning is also emitted as
data/docs/roadmap.md CHANGED
@@ -66,8 +66,12 @@
66
66
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
67
67
  RuboCop policy, and a warning-free Brakeman scan
68
68
  - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
69
- Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
70
- verified rather than assumed
69
+ Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised
70
+ range is verified rather than assumed. The compatibility job runs SQLite only;
71
+ the PostgreSQL and MySQL jobs run on the newest Rails, so adapter behavior on
72
+ Rails 7.1 and 7.2 is unmeasured against those servers. Rails 7.0 is out of
73
+ range because its SQLite adapter requires `sqlite3 ~> 1.4`, and this gem needs
74
+ the busy-handler control that arrived in `sqlite3` 2.x
71
75
  - A JavaScript suite covering every browser module, run in CI with Node's test
72
76
  runner and jsdom, plus a browser suite running the same modules against real
73
77
  Chromium and a real Turbo build, with every GitHub Actions reference pinned to
@@ -4,7 +4,11 @@ module SolidObjects
4
4
  class Actor
5
5
  EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
6
6
  CommitActionIntent = Data.define(:name, :arguments)
7
- ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
7
+ # The reminders table holds a name in 191 characters.
8
+ REMINDER_NAME_LIMIT = 191
9
+ REMINDER_KEY_SEPARATOR = ":"
10
+
11
+ ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
8
12
  OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
9
13
 
10
14
  class << self
@@ -197,8 +201,13 @@ module SolidObjects
197
201
  nil
198
202
  end
199
203
 
200
- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201
- def schedule(at:, every: nil, missed: :latest)
204
+ # A reminder is identified by its name, and without a key that name is the
205
+ # operation, so one actor holds one alarm per operation. A key gives an actor
206
+ # an alarm per item it is waiting on, which is what an actor holding a queue
207
+ # of scheduled work needs; the key is the caller's own identifier for the
208
+ # item, and scheduling the same key again moves that item's alarm.
209
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
210
+ def schedule(at:, every: nil, missed: :latest, key: nil)
202
211
  interval_seconds = every&.to_f
203
212
  if interval_seconds && !interval_seconds.positive?
204
213
  raise ArgumentError, "reminder interval must be positive"
@@ -207,13 +216,15 @@ module SolidObjects
207
216
  unless %w[all latest].include?(missed_policy)
208
217
  raise ArgumentError, "missed reminder policy must be all or latest"
209
218
  end
219
+ reminder_key = validated_reminder_key(key)
210
220
 
211
221
  OperationDispatcher.new(
212
222
  actor_type: self.class.actor_type,
213
223
  handlers: self.class.definition.messages
214
224
  ) do |operation, arguments|
215
225
  ReminderIntent.new(
216
- name: operation.to_s,
226
+ name: reminder_name(operation:, key: reminder_key),
227
+ operation: operation.to_s,
217
228
  at:,
218
229
  arguments: Serialization.dump(arguments),
219
230
  interval_seconds:,
@@ -225,6 +236,45 @@ module SolidObjects
225
236
  end
226
237
  end
227
238
 
239
+ # @rbs ((String | Symbol | Integer)?) -> String?
240
+ def validated_reminder_key(key)
241
+ return nil if key.nil?
242
+
243
+ reminder_key = key.to_s
244
+ raise ArgumentError, "reminder key must not be empty" if reminder_key.empty?
245
+
246
+ reminder_key
247
+ end
248
+
249
+ # A keyed name is the operation, a colon, and the key, so an operation
250
+ # holding a colon of its own would make two different schedules produce one
251
+ # name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
252
+ # row, and the second would silently take the first one's alarm. Refusing a
253
+ # colon in the operation keeps unkeyed names free of colons, which leaves
254
+ # the two kinds of name disjoint and lets a key hold colons of its own.
255
+ #
256
+ # The length is checked on the composed name rather than the key alone,
257
+ # because a long operation and a short key can exceed the column just as
258
+ # easily as the reverse. Both are refused here rather than at the insert,
259
+ # once the turn is already doing work.
260
+ # @rbs (operation: Symbol | String, key: String?) -> String
261
+ def reminder_name(operation:, key:)
262
+ operation_name = operation.to_s
263
+ if operation_name.include?(REMINDER_KEY_SEPARATOR)
264
+ raise ArgumentError,
265
+ "reminder operation #{operation_name.inspect} must not contain #{REMINDER_KEY_SEPARATOR.inspect}"
266
+ end
267
+ return operation_name if key.nil?
268
+
269
+ name = "#{operation_name}#{REMINDER_KEY_SEPARATOR}#{key}"
270
+ if name.length > REMINDER_NAME_LIMIT
271
+ raise ArgumentError,
272
+ "reminder name #{name.length} characters exceeds the #{REMINDER_NAME_LIMIT} the database holds"
273
+ end
274
+
275
+ name
276
+ end
277
+
228
278
  # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
229
279
  def send_to(reference, available_at: nil, idempotency_key: nil)
230
280
  actor_class = SolidObjects.registry.fetch(reference.actor_type)
@@ -0,0 +1,44 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Administration
5
+ # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
6
+ def processes(authorization_context: nil)
7
+ authorize!(authorization_context:)
8
+ now = SolidObjects.database_adapter.database_now
9
+ stale_at = now - SolidObjects.configuration.process_alive_threshold
10
+
11
+ Process.order(:kind, :started_at).map do |process_record|
12
+ {
13
+ id: process_record.id,
14
+ kind: process_record.kind,
15
+ hostname: process_record.hostname,
16
+ pid: process_record.pid,
17
+ metadata: Serialization.readonly_copy(process_record.metadata),
18
+ shutdown_state: process_record.shutdown_state,
19
+ shutdown_requested_at: process_record.shutdown_requested_at,
20
+ started_at: process_record.started_at,
21
+ last_heartbeat_at: process_record.last_heartbeat_at,
22
+ stopped_at: process_record.stopped_at,
23
+ stale: process_record.shutdown_state != "stopped" &&
24
+ process_record.last_heartbeat_at <= stale_at
25
+ }.freeze
26
+ end.freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs (?authorization_context: untyped) -> void
32
+ def authorize!(authorization_context: nil)
33
+ authorized = SolidObjects.configuration.authorize_administration.call(
34
+ action: :inspect,
35
+ resource: "processes",
36
+ resource_id: nil,
37
+ authorization_context:
38
+ )
39
+ return if authorized
40
+
41
+ raise Unauthorized, "actor administration is not authorized"
42
+ end
43
+ end
44
+ end
@@ -1,9 +1,9 @@
1
1
  # rbs_inline: enabled
2
2
 
3
- require_relative "../../app/models/solid_objects/record"
4
-
5
3
  module SolidObjects
6
4
  class Engine < ::Rails::Engine
5
+ RECORD_PATH = File.expand_path("../../app/models/solid_objects/record.rb", __dir__)
6
+
7
7
  isolate_namespace SolidObjects
8
8
 
9
9
  config.generators do |generators|
@@ -16,7 +16,9 @@ module SolidObjects
16
16
  end
17
17
 
18
18
  initializer "solid_objects.database", after: :load_config_initializers do
19
- SolidObjects::Record.configure_connection
19
+ ActiveSupport.on_load(:active_record) do
20
+ require RECORD_PATH
21
+ end
20
22
  end
21
23
 
22
24
  initializer "solid_objects.helpers" do
@@ -223,10 +223,10 @@ module SolidObjects
223
223
  end
224
224
 
225
225
  # A reminder is one named alarm per actor, so scheduling a name that is
226
- # already armed moves it rather than adding a second. An actor that arms a
227
- # reminder per queued item therefore keeps only the last, and nothing else
228
- # about that is visible: the write succeeds and the earlier wake-up simply
229
- # never happens.
226
+ # already armed moves it rather than adding a second. Without a key that
227
+ # name is the operation, so an actor arming a reminder per queued item keeps
228
+ # only the last; passing schedule a key gives each item its own name and so
229
+ # its own alarm.
230
230
  # Moves are returned rather than reported here, so the report happens after
231
231
  # the turn commits. A rolled back turn would otherwise announce an alarm
232
232
  # that never moved, which is the opposite of the visibility this event
@@ -239,7 +239,7 @@ module SolidObjects
239
239
  reminder.assign_attributes(
240
240
  actor_type: instance.actor_type,
241
241
  actor_id: instance.actor_id,
242
- operation: intent.name,
242
+ operation: intent.operation,
243
243
  arguments: intent.arguments,
244
244
  next_run_at: intent.at,
245
245
  interval_seconds: intent.interval_seconds,
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.13.1"
4
+ VERSION = "0.13.3"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -31,6 +31,7 @@ require "solid_objects/dead_letter_manager"
31
31
  require "solid_objects/message_pruner"
32
32
  require "solid_objects/instance_pruner"
33
33
  require "solid_objects/process_pruner"
34
+ require "solid_objects/administration"
34
35
  require "solid_objects/stream_name"
35
36
  require "solid_objects/dom_identity"
36
37
  require "solid_objects/stream_token"
@@ -137,6 +138,11 @@ module SolidObjects
137
138
  @dead_letters ||= DeadLetterManager.new
138
139
  end
139
140
 
141
+ # @rbs () -> Administration
142
+ def administration
143
+ @administration ||= Administration.new
144
+ end
145
+
140
146
  # @rbs (String | Symbol) -> String
141
147
  def table_name(name)
142
148
  "#{configuration.table_name_prefix}#{name}"
@@ -164,6 +170,7 @@ module SolidObjects
164
170
  @effect_registry = EffectRegistry.new
165
171
  @commit_action_registry = CommitActionRegistry.new
166
172
  @dead_letters = nil
173
+ @administration = nil
167
174
  end
168
175
 
169
176
  # @rbs () -> DatabaseAdapter
@@ -32,9 +32,16 @@ module SolidObjects
32
32
  def members: () -> [ :name, :arguments ]
33
33
  end
34
34
 
35
+ # The reminders table holds a name in 191 characters.
36
+ REMINDER_NAME_LIMIT: ::Integer
37
+
38
+ REMINDER_KEY_SEPARATOR: ::String
39
+
35
40
  class ReminderIntent < Data
36
41
  attr_reader name(): untyped
37
42
 
43
+ attr_reader operation(): untyped
44
+
38
45
  attr_reader at(): untyped
39
46
 
40
47
  attr_reader arguments(): untyped
@@ -43,12 +50,12 @@ module SolidObjects
43
50
 
44
51
  attr_reader missed_policy(): untyped
45
52
 
46
- def self.new: (untyped name, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
47
- | (name: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
53
+ def self.new: (untyped name, untyped operation, untyped at, untyped arguments, untyped interval_seconds, untyped missed_policy) -> instance
54
+ | (name: untyped, operation: untyped, at: untyped, arguments: untyped, interval_seconds: untyped, missed_policy: untyped) -> instance
48
55
 
49
- def self.members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
56
+ def self.members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
50
57
 
51
- def members: () -> [ :name, :at, :arguments, :interval_seconds, :missed_policy ]
58
+ def members: () -> [ :name, :operation, :at, :arguments, :interval_seconds, :missed_policy ]
52
59
  end
53
60
 
54
61
  class OutboundMessageIntent < Data
@@ -157,8 +164,30 @@ module SolidObjects
157
164
  # @rbs (Symbol | String, **untyped) -> nil
158
165
  def commit_action: (Symbol | String, **untyped) -> nil
159
166
 
160
- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
161
- def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
167
+ # A reminder is identified by its name, and without a key that name is the
168
+ # operation, so one actor holds one alarm per operation. A key gives an actor
169
+ # an alarm per item it is waiting on, which is what an actor holding a queue
170
+ # of scheduled work needs; the key is the caller's own identifier for the
171
+ # item, and scheduling the same key again moves that item's alarm.
172
+ # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
173
+ def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
174
+
175
+ # @rbs ((String | Symbol | Integer)?) -> String?
176
+ def validated_reminder_key: ((String | Symbol | Integer)?) -> String?
177
+
178
+ # A keyed name is the operation, a colon, and the key, so an operation
179
+ # holding a colon of its own would make two different schedules produce one
180
+ # name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
181
+ # row, and the second would silently take the first one's alarm. Refusing a
182
+ # colon in the operation keeps unkeyed names free of colons, which leaves
183
+ # the two kinds of name disjoint and lets a key hold colons of its own.
184
+ #
185
+ # The length is checked on the composed name rather than the key alone,
186
+ # because a long operation and a short key can exceed the column just as
187
+ # easily as the reverse. Both are refused here rather than at the insert,
188
+ # once the turn is already doing work.
189
+ # @rbs (operation: Symbol | String, key: String?) -> String
190
+ def reminder_name: (operation: Symbol | String, key: String?) -> String
162
191
 
163
192
  # @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
164
193
  def send_to: (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
@@ -0,0 +1,13 @@
1
+ # Generated from lib/solid_objects/administration.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class Administration
5
+ # @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
6
+ def processes: (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
7
+
8
+ private
9
+
10
+ # @rbs (?authorization_context: untyped) -> void
11
+ def authorize!: (?authorization_context: untyped) -> void
12
+ end
13
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Engine < ::Rails::Engine
5
+ RECORD_PATH: untyped
6
+
5
7
  include SolidObjects::ActorHelper
6
8
  end
7
9
  end
@@ -46,10 +46,10 @@ module SolidObjects
46
46
  def enqueue_effects: (message: Message, instance: Instance, intents: Array[Actor::EffectIntent]) -> Array[Effect]
47
47
 
48
48
  # A reminder is one named alarm per actor, so scheduling a name that is
49
- # already armed moves it rather than adding a second. An actor that arms a
50
- # reminder per queued item therefore keeps only the last, and nothing else
51
- # about that is visible: the write succeeds and the earlier wake-up simply
52
- # never happens.
49
+ # already armed moves it rather than adding a second. Without a key that
50
+ # name is the operation, so an actor arming a reminder per queued item keeps
51
+ # only the last; passing schedule a key gives each item its own name and so
52
+ # its own alarm.
53
53
  # Moves are returned rather than reported here, so the report happens after
54
54
  # the turn commits. A rolled back turn would otherwise announce an alarm
55
55
  # that never moved, which is the opposite of the visibility this event
@@ -36,6 +36,9 @@ module SolidObjects
36
36
  # @rbs () -> DeadLetterManager
37
37
  def self.dead_letters: () -> DeadLetterManager
38
38
 
39
+ # @rbs () -> Administration
40
+ def self.administration: () -> Administration
41
+
39
42
  # @rbs (String | Symbol) -> String
40
43
  def self.table_name: (String | Symbol) -> String
41
44
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.13.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-16 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -16,70 +16,70 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '8.0'
19
+ version: '7.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '8.0'
26
+ version: '7.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: actionpack
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '8.0'
33
+ version: '7.1'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '8.0'
40
+ version: '7.1'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: actionview
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: '8.0'
47
+ version: '7.1'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: '8.0'
54
+ version: '7.1'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: activerecord
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '8.0'
61
+ version: '7.1'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '8.0'
68
+ version: '7.1'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: activesupport
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '8.0'
75
+ version: '7.1'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '8.0'
82
+ version: '7.1'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: rack
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +100,14 @@ dependencies:
100
100
  requirements:
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: '8.0'
103
+ version: '7.1'
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
- version: '8.0'
110
+ version: '7.1'
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: thor
113
113
  requirement: !ruby/object:Gem::Requirement
@@ -382,6 +382,7 @@ files:
382
382
  - lib/solid_objects/actor_registry.rb
383
383
  - lib/solid_objects/actor_snapshot.rb
384
384
  - lib/solid_objects/actor_view.rb
385
+ - lib/solid_objects/administration.rb
385
386
  - lib/solid_objects/application_actor_loader.rb
386
387
  - lib/solid_objects/application_write_guard.rb
387
388
  - lib/solid_objects/broadcast_executor.rb
@@ -468,6 +469,7 @@ files:
468
469
  - sig/generated/lib/solid_objects/actor_registry.rbs
469
470
  - sig/generated/lib/solid_objects/actor_snapshot.rbs
470
471
  - sig/generated/lib/solid_objects/actor_view.rbs
472
+ - sig/generated/lib/solid_objects/administration.rbs
471
473
  - sig/generated/lib/solid_objects/application_actor_loader.rbs
472
474
  - sig/generated/lib/solid_objects/application_write_guard.rbs
473
475
  - sig/generated/lib/solid_objects/broadcast_executor.rbs