solid_objects 0.12.0 → 0.13.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +72 -6
  4. data/app/models/solid_objects/broadcast.rb +8 -0
  5. data/docs/adr/0009-realtime-updates.md +5 -1
  6. data/docs/architecture.md +4 -2
  7. data/docs/authorization.md +11 -4
  8. data/docs/correctness.md +3 -3
  9. data/docs/dashboard.md +200 -0
  10. data/docs/database-schema.md +5 -4
  11. data/docs/development.md +12 -0
  12. data/docs/realtime.md +37 -7
  13. data/docs/roadmap.md +28 -6
  14. data/docs/security.md +8 -0
  15. data/examples/application/README.md +5 -5
  16. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  17. data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
  18. data/lib/solid_objects/actor.rb +6 -5
  19. data/lib/solid_objects/actor_channel.rb +7 -4
  20. data/lib/solid_objects/actor_definition.rb +15 -3
  21. data/lib/solid_objects/actor_view.rb +6 -1
  22. data/lib/solid_objects/effect_executor.rb +10 -2
  23. data/lib/solid_objects/errors.rb +3 -0
  24. data/lib/solid_objects/executor.rb +7 -1
  25. data/lib/solid_objects/reminder_scheduler.rb +28 -14
  26. data/lib/solid_objects/test_helper.rb +16 -0
  27. data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
  28. data/lib/solid_objects/version.rb +1 -1
  29. data/lib/solid_objects/web/action.rb +109 -0
  30. data/lib/solid_objects/web/application.rb +239 -0
  31. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  32. data/lib/solid_objects/web/helpers.rb +227 -0
  33. data/lib/solid_objects/web/paginator.rb +64 -0
  34. data/lib/solid_objects/web/route.rb +55 -0
  35. data/lib/solid_objects/web/router.rb +46 -0
  36. data/lib/solid_objects/web/statistics.rb +78 -0
  37. data/lib/solid_objects/web.rb +222 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +2 -2
  39. data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
  40. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  41. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
  42. data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
  43. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  44. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  45. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  46. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  47. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  48. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  49. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  50. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  51. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  52. data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
  53. data/web/assets/javascripts/application.js +118 -0
  54. data/web/assets/javascripts/charts.js +190 -0
  55. data/web/assets/stylesheets/application.css +527 -0
  56. data/web/views/_messages.erb +29 -0
  57. data/web/views/_navigation.erb +15 -0
  58. data/web/views/_paging.erb +17 -0
  59. data/web/views/_status_filter.erb +8 -0
  60. data/web/views/_summary.erb +39 -0
  61. data/web/views/broadcasts.erb +35 -0
  62. data/web/views/dashboard.erb +128 -0
  63. data/web/views/dead_letter.erb +40 -0
  64. data/web/views/dead_letters.erb +42 -0
  65. data/web/views/effects.erb +35 -0
  66. data/web/views/instance.erb +139 -0
  67. data/web/views/instances.erb +49 -0
  68. data/web/views/layout.erb +22 -0
  69. data/web/views/mailbox.erb +35 -0
  70. data/web/views/message.erb +34 -0
  71. data/web/views/processes.erb +37 -0
  72. data/web/views/reminders.erb +37 -0
  73. metadata +55 -2
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,11 @@
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 invalidation-only observable broadcasts by default, explicit
28
+ `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
29
+ component locals, and authorized replace or morph refresh. Default
30
+ observables retain component change detection while storing and broadcasting
31
+ no projected value
28
32
  - Batched component refreshes: components sharing a signed `batch:` collapse to
29
33
  one browser request per revision, served as HTML frames in a JSON envelope
30
34
  - Personalized state payload broadcasts computed per subscriber under that
@@ -45,7 +49,8 @@
45
49
  - Bounded message/process pruning, actor-type opt-in instance expiration,
46
50
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
47
51
  helper that clears every actor-owned table itself rather than relying on the
48
- database cascade, which a host application may not enforce
52
+ database cascade, which a host application may not enforce, and runs due
53
+ reminders against an explicit test time without moving the database clock
49
54
  - Supervisor role replacement: a role whose thread dies is restarted until
50
55
  shutdown is requested, and dead process records plus expired message and
51
56
  process history are pruned on their own intervals without an application
@@ -103,8 +108,25 @@
103
108
  handing the block a raw Cable connection.
104
109
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
105
110
  distributed per-actor rate limits and global admission control do not.
106
- - Administration: actor and dead-letter views plus policy hooks exist; richer
107
- filtering, audit records, and bulk-safe tools do not.
111
+ - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
112
+ instances, mailbox, reminders, effects, broadcasts, dead letters, and
113
+ processes, with actor-type and actor-id filtering, status filters, paging, a
114
+ polled stats endpoint, Chart.js charts, and extension registration. The chart
115
+ library is fetched from a CDN with a subresource integrity hash, which a
116
+ deployment without outbound network access must replace with a vendored copy
117
+ or turn off. Every route declares its
118
+ own administration policy and a route declared without one raises at load
119
+ time, so the deny-by-default posture is enforced by construction rather than
120
+ by remembering to add a check. It changes only two things: an idempotent dead
121
+ letter retry and instance pause/resume. What does not exist is audit records
122
+ of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
123
+ because `DeadLetterManager` exposes no bulk operation. Pause is an operator
124
+ brake and not a stop, since a pass already in flight finishes its turn and a
125
+ synchronous caller waiting on a paused instance times out. The page cost was
126
+ reasoned about rather than measured: the summary bar issues a fixed set of
127
+ indexed aggregate queries per page, which is why `HEAD /` exists for uptime
128
+ monitors, but no dashboard latency has been benchmarked against a large
129
+ table.
108
130
 
109
131
  ## Next milestones
110
132
 
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
+ Observables are invalidation-only by default, so their durable rows and shared
40
+ actor stream carry no projected value. `broadcast: :value` deliberately stores
41
+ the value in the broadcast outbox and may send it to every subscriber that
42
+ passes `authorize_subscription` for the actor. Never opt credentials, session
43
+ identifiers, private cards, hidden library order, or any other
44
+ subscriber-specific state into value broadcasting. Use `broadcast_payload` for
45
+ a projection that 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
@@ -10,8 +10,8 @@ key. The chat actor also gives every submitted chat message a caller-generated
10
10
  message ID and checks that ID in durable actor state, because actor handlers may
11
11
  be redelivered.
12
12
 
13
- The views demonstrate scalar observable replacement and a live chat-message
14
- ERB component. The chat component receives `recent_messages` as an ordinary
15
- Ruby array, rerenders its `<ol>` after committed changes, and refreshes through
16
- the authenticated host request context. Turbo append intents remain roadmap
17
- work.
13
+ The views demonstrate explicit `broadcast: :value` scalar replacement and a
14
+ default invalidation-only chat-message ERB component. The chat component
15
+ receives `recent_messages` as an ordinary Ruby array, rerenders its `<ol>` after
16
+ committed changes, and refreshes through the authenticated host request
17
+ context. Turbo append intents remain roadmap work.
@@ -6,7 +6,7 @@ class ChatRoomActor < SolidObjects::Actor
6
6
  attribute :members, default: -> { [] }
7
7
  attribute :recent_messages, default: -> { [] }
8
8
 
9
- observable :presence do
9
+ observable :presence, broadcast: :value do
10
10
  members.length
11
11
  end
12
12
 
@@ -5,11 +5,11 @@ class ShoppingCartActor < SolidObjects::Actor
5
5
  attribute :checkout_status, default: "open"
6
6
  attribute :payment_id
7
7
 
8
- observable :items_count do
8
+ observable :items_count, broadcast: :value do
9
9
  items.sum { |item| item.fetch("quantity") }
10
10
  end
11
11
 
12
- observable :subtotal_cents do
12
+ observable :subtotal_cents, broadcast: :value do
13
13
  items.sum do |item|
14
14
  item.fetch("quantity") * item.fetch("unit_price_cents")
15
15
  end
@@ -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: :invalidation, &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.13.0"
5
5
  end
@@ -0,0 +1,109 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "erb"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # One request. A route handler runs inside an instance of this class, so a
10
+ # handler and the template it renders share the same helpers and the same
11
+ # request.
12
+ class Action
13
+ include Helpers
14
+
15
+ # @rbs @env: Hash[String, untyped]
16
+ # @rbs @route: Route
17
+ # @rbs @captures: Hash[Symbol, String?]
18
+ # @rbs @request: untyped
19
+ # @rbs @layout_rendered: bool
20
+ # @rbs @response_status: Integer
21
+
22
+ attr_reader :env, :route, :response_status
23
+
24
+ # @rbs (env: Hash[String, untyped], route: Route) -> void
25
+ def initialize(env:, route:)
26
+ @env = env
27
+ @route = route
28
+ @captures = route.capture(env["PATH_INFO"].to_s)
29
+ @layout_rendered = false
30
+ @response_status = 200
31
+ end
32
+
33
+ # Sets the status a rendered page is served with. `halt` and `redirect`
34
+ # stop the handler, so a page that must render its own body and still
35
+ # report a failure needs this instead.
36
+ # @rbs (Integer) -> void
37
+ def status(code)
38
+ @response_status = code
39
+ end
40
+
41
+ # @rbs () -> untyped
42
+ def request
43
+ @request ||= ::Rack::Request.new(env)
44
+ end
45
+
46
+ # @rbs () -> Hash[untyped, untyped]?
47
+ def session
48
+ env["rack.session"]
49
+ end
50
+
51
+ # @rbs (Symbol) -> String?
52
+ def route_params(key)
53
+ @captures[key]
54
+ end
55
+
56
+ # @rbs (String) -> untyped
57
+ def url_params(key)
58
+ request.params[key]
59
+ end
60
+
61
+ # @rbs () -> untyped
62
+ def call
63
+ instance_exec(&route.handler)
64
+ end
65
+
66
+ # The layout is rendered once per request. The flag is raised before the
67
+ # page body runs so a partial the body renders returns its own fragment
68
+ # rather than a second whole page. The layout reads the page it wraps
69
+ # from `locals.fetch(:content)`.
70
+ # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String
71
+ def erb(name, locals = {})
72
+ return evaluate(Web.template(name), locals) if @layout_rendered
73
+
74
+ @layout_rendered = true
75
+ content = evaluate(Web.template(name), locals)
76
+ evaluate(Web.template(:layout), { content: })
77
+ end
78
+
79
+ # @rbs (Integer, ?String) -> void
80
+ def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error"))
81
+ throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ]
82
+ end
83
+
84
+ # @rbs (String) -> void
85
+ def redirect(path)
86
+ throw :halt, [ 302, { "location" => path_to(path) }, [] ]
87
+ end
88
+
89
+ # @rbs (untyped) -> void
90
+ def json(payload)
91
+ throw :halt, [
92
+ 200,
93
+ { "content-type" => "application/json", "cache-control" => "private, no-store" },
94
+ [ JSON.generate(payload) ]
95
+ ]
96
+ end
97
+
98
+ private
99
+
100
+ # `locals` is a local variable of this method, so a template reads it by
101
+ # name, and every helper is reachable because the template runs against
102
+ # this object.
103
+ # @rbs (untyped, Hash[Symbol, untyped]) -> String
104
+ def evaluate(template, locals)
105
+ template.result(binding)
106
+ end
107
+ end
108
+ end
109
+ end