solid_objects 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15ee987ca3dcf3419f28902e2a0c18c8444c834964b743f3e7b99cd5f6dcde33
4
- data.tar.gz: 45bf4d0449184c9fbf3718ce088f8541b68049e9458ee8802947fd74bef1fadb
3
+ metadata.gz: 7e89f40dd095ec1d8934ff47872f3638ca441203186a7a1562dc9f603c939150
4
+ data.tar.gz: 23389e8737fcbfc816ca41aedf56f4b83baa6769fc2c54f6d9ca3622c67ca135
5
5
  SHA512:
6
- metadata.gz: 6b5b29a7f3052c1a04a012f348a9f8f1054500056073b34c8b2a6680f7d84db195efbe0d5d68809b33ba7bed04af1c141777a27801e6049df0863311cd948a25
7
- data.tar.gz: 93e7bdeb0ee3a1a0c4f212f5da40c25d61bca88b1ea2dc87efb4b09d199068b5374452beb991baec05b573ac3e0109ca86dde37463a7cf5f28d823f02969767a
6
+ metadata.gz: d28e8e826c045dc546a13eb34631c692281b82b10a55205f0f6b3a17b77a1a2144bbfb786c69c86d701d42d356c65da4c26d7dd2f25a5538f9743e4405aa53b1
7
+ data.tar.gz: 34c89447e1592ea056d511db1fbf01de898c1563c34616a7b80058b0c1bb649b3a4e01cf7f1fad6de35bedc82e484c0a8c96b21aa0be1d959b94c3492fd3d3c4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1 - 2026-08-13
4
+
5
+ - Add invalidation-only observables with `broadcast: :invalidation`. They still
6
+ detect changes and refresh reactive components, but persist `{}` and send no
7
+ scalar value over Action Cable. Document that ordinary observable values are
8
+ shared with every authorized actor subscriber and that subscriber-specific
9
+ state belongs in a payload projection.
10
+ - **Breaking:** include the originally staged `arguments:` in effect success
11
+ and failure callbacks so actors can correlate concurrent effects.
12
+ - Accept identifier-style rejection codes, including camelCase and symbols,
13
+ and fail malformed codes once with non-retryable
14
+ `SolidObjects::InvalidRejectionCode` diagnostics.
15
+ - Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
16
+ reminder tests without sleeping or mutating runtime rows.
17
+
3
18
  ## 0.12.0 - 2026-08-13
4
19
 
5
20
  - Replace positional actor dispatch with fluent operation selection. Direct
data/README.md CHANGED
@@ -205,6 +205,21 @@ dependencies changes:
205
205
  <% end %>
206
206
  ```
207
207
 
208
+ An observable's value is shared with every authorized subscriber by default
209
+ and is stored in `solid_objects_broadcasts`. Use an invalidation-only observable
210
+ for component dependencies whose value is private or subscriber-specific:
211
+
212
+ ```ruby
213
+ observable :player_one, broadcast: :invalidation do
214
+ player_in_seat(1)
215
+ end
216
+ ```
217
+
218
+ Its value is still available to the authorized component renderer, but the
219
+ durable row and Action Cable frame carry only invalidation metadata. It cannot
220
+ be rendered as a scalar `<span>`. Put per-viewer state in `broadcast_payload`,
221
+ which computes a fresh projection for each connection.
222
+
208
223
  Component names can repeat when each instance has a stable key. Signed
209
224
  JSON-compatible locals let one conventional partial render the matching
210
225
  projection:
@@ -736,8 +751,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
736
751
  state is rolled back, and no later mailbox turn is blocked.
737
752
 
738
753
  `Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
739
- match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
740
- handler calls `reject`.
754
+ match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
755
+ `SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
741
756
 
742
757
  ### Redelivery
743
758
 
@@ -819,11 +834,11 @@ def checkout(payment_id:, amount_cents:)
819
834
  )
820
835
  end
821
836
 
822
- def payment_succeeded(effect_id:, result:)
837
+ def payment_succeeded(effect_id:, arguments:, result:)
823
838
  self.checkout_status = "paid"
824
839
  end
825
840
 
826
- def payment_failed(effect_id:, error:)
841
+ def payment_failed(effect_id:, arguments:, error:)
827
842
  self.checkout_status = "failed"
828
843
  end
829
844
  ```
@@ -842,6 +857,10 @@ end
842
857
 
843
858
  The provider call can repeat if a process dies after external success but
844
859
  before recording completion. The stable effect ID is the idempotency key.
860
+ Success callbacks receive `effect_id:`, the originally staged `arguments:`,
861
+ and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
862
+ `error:`, so an actor can correlate concurrent effects without storing a
863
+ separate callback ledger.
845
864
 
846
865
  ## Reminders
847
866
 
@@ -6,5 +6,13 @@ module SolidObjects
6
6
 
7
7
  belongs_to :message, class_name: "SolidObjects::Message"
8
8
  belongs_to :instance, class_name: "SolidObjects::Instance"
9
+
10
+ # @rbs () -> bool
11
+ def broadcasts_value?
12
+ return false if observable_name == PayloadBroadcast::REVISION_OBSERVABLE
13
+
14
+ actor_class = SolidObjects.registry.fetch(instance.actor_type)
15
+ actor_class.definition.broadcasts_observable_value?(observable_name)
16
+ end
9
17
  end
10
18
  end
data/docs/architecture.md CHANGED
@@ -113,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
113
113
 
114
114
  ### Effect worker
115
115
 
116
- An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
116
+ An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages and receive the effect ID, originally staged arguments, and result or error.
117
117
 
118
118
  ### Reminder scheduler
119
119
 
@@ -169,7 +169,8 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
169
169
  `message` and `query` both execute as durable mailbox turns. A query may not
170
170
  mutate state. The executor detects query mutation and fails the message. An
171
171
  observable is a named projection of state used by server rendering and realtime
172
- updates; it is not independently persisted.
172
+ updates. Its durable broadcast row stores the projected value by default;
173
+ `broadcast: :invalidation` stores only an empty invalidation marker.
173
174
 
174
175
  Lifecycle hooks are deterministic local hooks:
175
176
 
@@ -92,8 +92,8 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
92
92
  ### `broadcasts`
93
93
 
94
94
  Durable observable-change outbox. The unique message/observable key prevents
95
- duplicate rows for one actor turn. Rows contain the observable JSON value and
96
- message/instance references used to derive invalidation metadata, never
95
+ duplicate rows for one actor turn. Rows contain the observable JSON value, or
96
+ `{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
97
97
  personalized rendered HTML. Claim and delivery indexes support retries and
98
98
  cleanup.
99
99
 
data/docs/development.md CHANGED
@@ -65,6 +65,18 @@ assert_equal "completed", message.status
65
65
  Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
66
66
  reminders pending.
67
67
 
68
+ Rails time travel does not move the database clock used by reminder claims. Run
69
+ future reminders against an explicit test instant instead of updating runtime
70
+ rows or sleeping:
71
+
72
+ ```ruby
73
+ assert_equal 1, run_due_reminders(now: 5.minutes.from_now)
74
+ assert_equal 1, drain_solid_objects(roles: [ :actors ])
75
+ ```
76
+
77
+ The explicit instant controls due selection and recurring schedule advancement.
78
+ Claim timestamps and stale-process recovery still use database time.
79
+
68
80
  `SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
69
81
  boundaries. It deletes every actor-owned row itself rather than deleting actor
70
82
  instances and letting the database cascade remove the rest: SQLite has to be
data/docs/realtime.md CHANGED
@@ -65,6 +65,27 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
65
65
  invalidation correct and prevents a partial from silently depending on state
66
66
  that cannot wake it.
67
67
 
68
+ Observable values are shared projections. By default, each changed value is
69
+ stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
70
+ every subscriber that passes `authorize_subscription`. Authorization to the
71
+ actor stream is not a per-viewer projection.
72
+
73
+ For a component dependency whose value must never enter the durable outbox or
74
+ Action Cable frame, declare it invalidation-only:
75
+
76
+ ```ruby
77
+ observable :player_one, broadcast: :invalidation do
78
+ player_in_seat(1)
79
+ end
80
+ ```
81
+
82
+ The runtime still compares the value around each successful turn and uses a
83
+ change to refresh components, but stores `{}` and renders no scalar Turbo
84
+ replacement. An invalidation-only observable therefore cannot be used as a
85
+ scalar value such as `actor.player_one`. The component endpoint reads the
86
+ latest committed value and authorizes it again; subscriber-specific state
87
+ belongs in `broadcast_payload`.
88
+
68
89
  ```erb
69
90
  <ul>
70
91
  <% actor.recent_messages.each do |message| %>
@@ -416,7 +437,8 @@ component key, locals, or DOM ID.
416
437
  ## Broadcast durability
417
438
 
418
439
  The actor's fenced commit compares observables before and after the turn and
419
- inserts one broadcast row per changed value. The actor state, monotonic
440
+ inserts one broadcast row per changed observable. Value-broadcast observables
441
+ store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
420
442
  `state_revision`, message completion, and broadcast rows commit atomically. A
421
443
  rolled-back or fenced-out turn therefore cannot invalidate a component.
422
444
 
@@ -456,8 +478,8 @@ response revision fencing.
456
478
  ## Cost model
457
479
 
458
480
  The durable row cost is unchanged: one broadcast row per changed observable,
459
- containing its JSON value and the message/instance references needed to derive
460
- invalidation metadata. No rendered document is stored. Each affected component
481
+ containing either its JSON value or an empty invalidation marker plus the
482
+ message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
461
483
  adds one authorized GET and one partial render per non-coalesced state
462
484
  revision. A repeated keyed component adds one GET and render per key. Signed
463
485
  locals increase page and Cable subscription bytes but do not create durable
data/docs/roadmap.md CHANGED
@@ -12,7 +12,8 @@
12
12
  - Bounded activation passes, idle cache, hot-actor yield, and process records
13
13
  - At-least-once retries, terminal domain rejection, strict poison ordering,
14
14
  dead letters, and tail retry
15
- - Transactional effects with success/failure actor messages
15
+ - Transactional effects with success/failure actor messages carrying the
16
+ originally staged arguments for callback correlation
16
17
  - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
17
18
  portable status rows with polling indexes and database check constraints on
18
19
  status, which works on all three adapters; a future version may add narrow
@@ -23,8 +24,10 @@
23
24
  listed here while broken in that worker: the scheduler reached a constant the
24
25
  caller path happened to load, so reminders never fired in production and
25
26
  every in-process test still passed
26
- - Durable observable invalidations, scalar Turbo replacement, keyed ERB
27
- components, signed component locals, and authorized replace or morph refresh
27
+ - Durable value or invalidation-only observable broadcasts, scalar Turbo
28
+ replacement, keyed ERB components, signed component locals, and authorized
29
+ replace or morph refresh. Invalidation-only observables retain component
30
+ change detection while storing and broadcasting no projected value
28
31
  - Batched component refreshes: components sharing a signed `batch:` collapse to
29
32
  one browser request per revision, served as HTML frames in a JSON envelope
30
33
  - Personalized state payload broadcasts computed per subscriber under that
@@ -45,7 +48,8 @@
45
48
  - Bounded message/process pruning, actor-type opt-in instance expiration,
46
49
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
47
50
  helper that clears every actor-owned table itself rather than relying on the
48
- database cascade, which a host application may not enforce
51
+ database cascade, which a host application may not enforce, and runs due
52
+ reminders against an explicit test time without moving the database clock
49
53
  - Supervisor role replacement: a role whose thread dies is restarted until
50
54
  shutdown is requested, and dead process records plus expired message and
51
55
  process history are pruned on their own intervals without an application
data/docs/security.md CHANGED
@@ -36,6 +36,14 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
36
36
  authorization. Signed stream tokens are readable by their recipient and prove
37
37
  integrity only.
38
38
 
39
+ Every normal observable value is stored in the broadcast outbox and can reach
40
+ every subscriber that passes `authorize_subscription` for the actor. Never put
41
+ credentials, session identifiers, private cards, hidden library order, or any
42
+ other subscriber-specific state in a value-broadcast observable. Declare a
43
+ component dependency with `broadcast: :invalidation` when only change metadata
44
+ may cross the shared stream, or use `broadcast_payload` for a projection that
45
+ must be computed separately for each authorized connection.
46
+
39
47
  ## Serialization
40
48
 
41
49
  The built-in serializer accepts JSON-compatible data, normalizes keys to
@@ -64,14 +64,14 @@ class ShoppingCartActor < SolidObjects::Actor
64
64
  )
65
65
  end
66
66
 
67
- def payment_succeeded(effect_id:, result:)
67
+ def payment_succeeded(effect_id:, arguments:, result:)
68
68
  return unless checkout_status == "pending"
69
- return unless result.fetch("payment_id") == payment_id
69
+ return unless arguments.fetch("payment_id") == payment_id
70
70
 
71
71
  self.checkout_status = "paid"
72
72
  end
73
73
 
74
- def payment_failed(effect_id:, error:)
74
+ def payment_failed(effect_id:, arguments:, error:)
75
75
  return unless checkout_status == "pending"
76
76
 
77
77
  self.checkout_status = "payment_failed"
@@ -52,9 +52,9 @@ module SolidObjects
52
52
  definition.add_query(name, block)
53
53
  end
54
54
 
55
- # @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
56
- def observable(name, &block)
57
- definition.add_observable(name, block)
55
+ # @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
56
+ def observable(name, broadcast: :value, &block)
57
+ definition.add_observable(name, block, broadcast:)
58
58
  end
59
59
 
60
60
  # @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
@@ -163,8 +163,9 @@ module SolidObjects
163
163
  # @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
164
164
  def reject(code, message, details: {})
165
165
  rejection_code = code.to_s
166
- unless rejection_code.match?(/\A[a-z][a-z0-9_]*\z/)
167
- raise ArgumentError, "rejection code must contain lowercase letters, digits, and underscores"
166
+ unless rejection_code.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
167
+ raise InvalidRejectionCode,
168
+ "invalid rejection code #{rejection_code.inspect}; expected a letter or underscore followed by letters, digits, or underscores"
168
169
  end
169
170
 
170
171
  raise Rejected.new(code: rejection_code, message:, details:)
@@ -165,13 +165,13 @@ module SolidObjects
165
165
  def validate_scalar_observables!
166
166
  return unless scalar_observables
167
167
 
168
- observables = SolidObjects
168
+ definition = SolidObjects
169
169
  .registry
170
170
  .fetch(reference.actor_type)
171
171
  .definition
172
- .observables
173
172
  unknown = scalar_observables.find do |name|
174
- !observables.key?(name.to_sym)
173
+ !definition.observables.key?(name.to_sym) ||
174
+ !definition.broadcasts_observable_value?(name)
175
175
  end
176
176
  return unless unknown
177
177
 
@@ -182,7 +182,10 @@ module SolidObjects
182
182
  def scalar_observable_names(snapshot)
183
183
  return scalar_observables if scalar_observables
184
184
 
185
- snapshot.actor_class.definition.observables.keys.map(&:to_s)
185
+ definition = snapshot.actor_class.definition
186
+ definition.observables.keys.filter_map do |name|
187
+ name.to_s if definition.broadcasts_observable_value?(name)
188
+ end
186
189
  end
187
190
  end
188
191
  end
@@ -9,6 +9,7 @@ module SolidObjects
9
9
  # @rbs @messages: Hash[Symbol, Handler]
10
10
  # @rbs @queries: Hash[Symbol, Handler]
11
11
  # @rbs @observables: Hash[Symbol, Handler]
12
+ # @rbs @observable_broadcasts: Hash[Symbol, Symbol]
12
13
  # @rbs @payload_broadcasts: Hash[Symbol, Handler]
13
14
  # @rbs @state_version: Integer
14
15
  # @rbs @state_migrations: Array[StateMigration]
@@ -33,6 +34,7 @@ module SolidObjects
33
34
  @messages = {}
34
35
  @queries = {}
35
36
  @observables = {}
37
+ @observable_broadcasts = {}
36
38
  @payload_broadcasts = {}
37
39
  @state_version = 1
38
40
  @state_migrations = []
@@ -76,17 +78,26 @@ module SolidObjects
76
78
  add_handler(collection: queries, name:, block:)
77
79
  end
78
80
 
79
- # @rbs (Symbol | String, Proc?) -> Handler
80
- def add_observable(name, block = nil)
81
+ # @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
82
+ def add_observable(name, block = nil, broadcast:)
81
83
  observable_name = name.to_sym
82
84
  raise InvalidActor, "#{observable_name.inspect} observable is already defined" if observables.key?(observable_name)
85
+ unless %i[value invalidation].include?(broadcast)
86
+ raise InvalidActor, "observable broadcast must be :value or :invalidation"
87
+ end
83
88
 
84
89
  observable_block = block || -> { state.fetch(observable_name) }
85
90
  Handler.new(name: observable_name, block: observable_block).tap do |handler|
86
91
  observables[observable_name] = handler
92
+ observable_broadcasts[observable_name] = broadcast
87
93
  end
88
94
  end
89
95
 
96
+ # @rbs (Symbol | String) -> bool
97
+ def broadcasts_observable_value?(name)
98
+ observable_broadcasts.fetch(name.to_sym) == :value
99
+ end
100
+
90
101
  # @rbs (Symbol | String, Proc) -> Handler
91
102
  def add_payload_broadcast(name, block)
92
103
  payload_name = name.to_sym
@@ -168,6 +179,7 @@ module SolidObjects
168
179
  copy.instance_variable_set(:@messages, messages.dup)
169
180
  copy.instance_variable_set(:@queries, queries.dup)
170
181
  copy.instance_variable_set(:@observables, observables.dup)
182
+ copy.instance_variable_set(:@observable_broadcasts, observable_broadcasts.dup)
171
183
  copy.instance_variable_set(:@payload_broadcasts, payload_broadcasts.dup)
172
184
  copy.instance_variable_set(:@state_version, state_version)
173
185
  copy.instance_variable_set(:@state_migrations, state_migrations.dup)
@@ -180,7 +192,7 @@ module SolidObjects
180
192
 
181
193
  private
182
194
 
183
- attr_reader :attribute_queries, :method_messages
195
+ attr_reader :attribute_queries, :method_messages, :observable_broadcasts
184
196
 
185
197
  # @rbs (Symbol) -> Handler
186
198
  def add_method_message(name)
@@ -24,8 +24,13 @@ module SolidObjects
24
24
  # @rbs (Symbol | String) -> untyped
25
25
  def value(name)
26
26
  observable_name = name.to_sym
27
- handler = snapshot.actor_class.definition.observables[observable_name]
27
+ definition = snapshot.actor_class.definition
28
+ handler = definition.observables[observable_name]
28
29
  raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
30
+ unless definition.broadcasts_observable_value?(observable_name)
31
+ raise ArgumentError,
32
+ "invalidation-only observable #{observable_name.inspect} cannot render as a scalar target"
33
+ end
29
34
 
30
35
  authorize_read!(observable_name)
31
36
  value = snapshot.observable_value(observable_name)
@@ -153,7 +153,11 @@ module SolidObjects
153
153
  effect: locked_effect,
154
154
  operation: locked_effect.success_operation,
155
155
  outcome: "success",
156
- arguments: { "effect_id" => locked_effect.effect_id, "result" => serialized_result }
156
+ arguments: {
157
+ "effect_id" => locked_effect.effect_id,
158
+ "arguments" => locked_effect.arguments,
159
+ "result" => serialized_result
160
+ }
157
161
  )
158
162
  locked_effect.update!(
159
163
  status: "completed",
@@ -191,7 +195,11 @@ module SolidObjects
191
195
  effect: locked_effect,
192
196
  operation: locked_effect.failure_operation,
193
197
  outcome: "failure",
194
- arguments: { "effect_id" => locked_effect.effect_id, "error" => error_details }
198
+ arguments: {
199
+ "effect_id" => locked_effect.effect_id,
200
+ "arguments" => locked_effect.arguments,
201
+ "error" => error_details
202
+ }
195
203
  )
196
204
  end
197
205
  locked_effect.update!(
@@ -205,6 +205,9 @@ module SolidObjects
205
205
  class UnknownCommitAction < NonRetryableError
206
206
  end
207
207
 
208
+ class InvalidRejectionCode < NonRetryableError
209
+ end
210
+
208
211
  class Rejected < Error
209
212
  # @rbs @code: String
210
213
  # @rbs @details: Hash[String, untyped]
@@ -301,12 +301,18 @@ module SolidObjects
301
301
  end
302
302
 
303
303
  broadcasts.each do |observable_name, value|
304
+ stored_value = if observable_name != PayloadBroadcast::REVISION_OBSERVABLE &&
305
+ actor.class.definition.broadcasts_observable_value?(observable_name)
306
+ value
307
+ else
308
+ {}
309
+ end
304
310
  Broadcast.create!(
305
311
  message:,
306
312
  instance:,
307
313
  broadcast_id: SecureRandom.uuid,
308
314
  observable_name:,
309
- value:,
315
+ value: stored_value,
310
316
  state_version: actor.class.state_version,
311
317
  activation_generation: activation.lease.generation,
312
318
  status: "pending",
@@ -19,15 +19,16 @@ module SolidObjects
19
19
  @shutdown_requested = false
20
20
  end
21
21
 
22
- # @rbs () -> bool
23
- def run_once
22
+ # @rbs (?now: Time?) -> bool
23
+ def run_once(now: nil)
24
24
  return false if stopped?
25
25
 
26
+ now = normalize_test_time(now)
26
27
  process_registry.heartbeat
27
- reminder = claim_next
28
+ reminder = claim_next(now:)
28
29
  return false unless reminder
29
30
 
30
- enqueue(reminder).present?
31
+ enqueue(reminder, now:).present?
31
32
  rescue
32
33
  release(reminder) if reminder
33
34
  raise
@@ -74,13 +75,14 @@ module SolidObjects
74
75
 
75
76
  attr_reader :process_registry, :database_adapter
76
77
 
77
- # @rbs () -> Reminder?
78
- def claim_next
78
+ # @rbs (now: Time?) -> Reminder?
79
+ def claim_next(now:)
79
80
  database_adapter.transaction do
80
- now = database_adapter.database_now
81
- stale_at = now - SolidObjects.configuration.process_alive_threshold
81
+ database_now = database_adapter.database_now
82
+ due_at = now || database_now
83
+ stale_at = database_now - SolidObjects.configuration.process_alive_threshold
82
84
  relation = Reminder
83
- .where(status: "scheduled", next_run_at: ..now)
85
+ .where(status: "scheduled", next_run_at: ..due_at)
84
86
  .where("claimed_by IS NULL OR claimed_at <= ?", stale_at)
85
87
  .order(:next_run_at, :id)
86
88
  reminder = database_adapter.lock_candidates(relation).first
@@ -88,14 +90,14 @@ module SolidObjects
88
90
 
89
91
  reminder.update!(
90
92
  claimed_by: process_registry.process_record.id,
91
- claimed_at: now
93
+ claimed_at: database_now
92
94
  )
93
95
  reminder
94
96
  end
95
97
  end
96
98
 
97
- # @rbs (Reminder) -> MessageReference?
98
- def enqueue(reminder)
99
+ # @rbs (Reminder, now: Time?) -> MessageReference?
100
+ def enqueue(reminder, now:)
99
101
  actor_class = SolidObjects.registry.fetch(reminder.actor_type)
100
102
  unless actor_class.definition.messages.key?(reminder.operation.to_sym)
101
103
  raise UnknownMessage, "unknown reminder operation #{reminder.operation.inspect}"
@@ -109,7 +111,7 @@ module SolidObjects
109
111
  locked_reminder = Reminder.lock.find_by(id: reminder.id)
110
112
  next unless locked_reminder
111
113
  verify_claim!(locked_reminder)
112
- now = database_adapter.database_now
114
+ schedule_now = now || database_adapter.database_now
113
115
  message = mailbox.enqueue_in_transaction(
114
116
  reference: Reference.new(actor_type: instance.actor_type, actor_id: instance.actor_id),
115
117
  operation: locked_reminder.operation,
@@ -122,7 +124,7 @@ module SolidObjects
122
124
  locked_reminder.update!(
123
125
  status: recurring ? "scheduled" : "completed",
124
126
  occurrence: locked_reminder.occurrence + 1,
125
- next_run_at: recurring ? next_run_at(locked_reminder, now) : locked_reminder.next_run_at,
127
+ next_run_at: recurring ? next_run_at(locked_reminder, schedule_now) : locked_reminder.next_run_at,
126
128
  claimed_by: nil,
127
129
  claimed_at: nil
128
130
  )
@@ -164,5 +166,17 @@ module SolidObjects
164
166
  missed_intervals = ((now - next_run) / interval).floor + 1
165
167
  next_run + (missed_intervals * interval)
166
168
  end
169
+
170
+ # @rbs (Time?) -> Time?
171
+ def normalize_test_time(now)
172
+ return unless now
173
+
174
+ time = now.to_time
175
+ return time if time.to_f.finite?
176
+
177
+ raise ArgumentError
178
+ rescue ArgumentError, NoMethodError, RangeError
179
+ raise ArgumentError, "reminder test time must be a valid time"
180
+ end
167
181
  end
168
182
  end
@@ -68,6 +68,22 @@ module SolidObjects
68
68
  runners&.uniq&.each(&:stop)
69
69
  end
70
70
 
71
+ # @rbs (now: Time, ?max_reminders: Integer) -> Integer
72
+ def run_due_reminders(now:, max_reminders: 10_000)
73
+ unless max_reminders.is_a?(Integer) && max_reminders.positive?
74
+ raise ArgumentError, "max_reminders must be a positive integer"
75
+ end
76
+
77
+ scheduler = ReminderScheduler.new
78
+ processed = 0
79
+ while processed < max_reminders && scheduler.run_once(now:)
80
+ processed += 1
81
+ end
82
+ processed
83
+ ensure
84
+ scheduler&.stop
85
+ end
86
+
71
87
  private
72
88
 
73
89
  # @rbs (Array[Symbol]) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
@@ -16,14 +16,14 @@ module SolidObjects
16
16
  actor_type: broadcast.instance.actor_type,
17
17
  actor_id: broadcast.instance.actor_id
18
18
  )
19
- stream = if broadcast.observable_name == PayloadBroadcast::REVISION_OBSERVABLE
20
- ""
21
- else
19
+ stream = if broadcast.broadcasts_value?
22
20
  observable_value(
23
21
  reference:,
24
22
  name: broadcast.observable_name,
25
23
  value: broadcast.value
26
24
  )
25
+ else
26
+ ""
27
27
  end
28
28
  metadata = Base64.urlsafe_encode64(
29
29
  JSON.generate(
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.12.0"
4
+ VERSION = "0.12.1"
5
5
  end
@@ -87,8 +87,8 @@ module SolidObjects
87
87
  # @rbs (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
88
88
  def self.query: (Symbol | String) { (*untyped, **untyped) -> untyped } -> ActorDefinition::Handler
89
89
 
90
- # @rbs (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
91
- def self.observable: (Symbol | String) ?{ () -> untyped } -> ActorDefinition::Handler
90
+ # @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
91
+ def self.observable: (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
92
92
 
93
93
  # @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
94
94
  def self.broadcast_payload: (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
@@ -44,6 +44,8 @@ module SolidObjects
44
44
 
45
45
  @payload_broadcasts: Hash[Symbol, Handler]
46
46
 
47
+ @observable_broadcasts: Hash[Symbol, Symbol]
48
+
47
49
  @observables: Hash[Symbol, Handler]
48
50
 
49
51
  @queries: Hash[Symbol, Handler]
@@ -82,8 +84,11 @@ module SolidObjects
82
84
  # @rbs (Symbol | String, Proc) -> Handler
83
85
  def add_query: (Symbol | String, Proc) -> Handler
84
86
 
85
- # @rbs (Symbol | String, Proc?) -> Handler
86
- def add_observable: (Symbol | String, Proc?) -> Handler
87
+ # @rbs (Symbol | String, Proc?, broadcast: Symbol) -> Handler
88
+ def add_observable: (Symbol | String, Proc?, broadcast: Symbol) -> Handler
89
+
90
+ # @rbs (Symbol | String) -> bool
91
+ def broadcasts_observable_value?: (Symbol | String) -> bool
87
92
 
88
93
  # @rbs (Symbol | String, Proc) -> Handler
89
94
  def add_payload_broadcast: (Symbol | String, Proc) -> Handler
@@ -109,6 +114,8 @@ module SolidObjects
109
114
 
110
115
  attr_reader method_messages: untyped
111
116
 
117
+ attr_reader observable_broadcasts: untyped
118
+
112
119
  # @rbs (Symbol) -> Handler
113
120
  def add_method_message: (Symbol) -> Handler
114
121
 
@@ -179,6 +179,9 @@ module SolidObjects
179
179
  class UnknownCommitAction < NonRetryableError
180
180
  end
181
181
 
182
+ class InvalidRejectionCode < NonRetryableError
183
+ end
184
+
182
185
  class Rejected < Error
183
186
  @code: String
184
187
 
@@ -13,8 +13,8 @@ module SolidObjects
13
13
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
14
14
  def initialize: (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
15
15
 
16
- # @rbs () -> bool
17
- def run_once: () -> bool
16
+ # @rbs (?now: Time?) -> bool
17
+ def run_once: (?now: Time?) -> bool
18
18
 
19
19
  # @rbs () -> void
20
20
  def stop: () -> void
@@ -37,11 +37,11 @@ module SolidObjects
37
37
 
38
38
  attr_reader database_adapter: untyped
39
39
 
40
- # @rbs () -> Reminder?
41
- def claim_next: () -> Reminder?
40
+ # @rbs (now: Time?) -> Reminder?
41
+ def claim_next: (now: Time?) -> Reminder?
42
42
 
43
- # @rbs (Reminder) -> MessageReference?
44
- def enqueue: (Reminder) -> MessageReference?
43
+ # @rbs (Reminder, now: Time?) -> MessageReference?
44
+ def enqueue: (Reminder, now: Time?) -> MessageReference?
45
45
 
46
46
  # @rbs (Reminder) -> void
47
47
  def release: (Reminder) -> void
@@ -51,5 +51,8 @@ module SolidObjects
51
51
 
52
52
  # @rbs (Reminder, Time) -> Time
53
53
  def next_run_at: (Reminder, Time) -> Time
54
+
55
+ # @rbs (Time?) -> Time?
56
+ def normalize_test_time: (Time?) -> Time?
54
57
  end
55
58
  end
@@ -26,6 +26,9 @@ module SolidObjects
26
26
  # @rbs (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
27
27
  def drain_solid_objects: (?roles: Array[Symbol], ?max_passes: Integer) -> Integer
28
28
 
29
+ # @rbs (now: Time, ?max_reminders: Integer) -> Integer
30
+ def run_due_reminders: (now: Time, ?max_reminders: Integer) -> Integer
31
+
29
32
  private
30
33
 
31
34
  # @rbs (Array[Symbol]) -> Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
@@ -2,5 +2,7 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Broadcast < Record
5
+ # @rbs () -> bool
6
+ def broadcasts_value?: () -> bool
5
7
  end
6
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson