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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +72 -6
- data/app/models/solid_objects/broadcast.rb +8 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +4 -2
- data/docs/authorization.md +11 -4
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +12 -0
- data/docs/realtime.md +37 -7
- data/docs/roadmap.md +28 -6
- data/docs/security.md +8 -0
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +5 -5
- data/lib/solid_objects/actor.rb +6 -5
- data/lib/solid_objects/actor_channel.rb +7 -4
- data/lib/solid_objects/actor_definition.rb +15 -3
- data/lib/solid_objects/actor_view.rb +6 -1
- data/lib/solid_objects/effect_executor.rb +10 -2
- data/lib/solid_objects/errors.rb +3 -0
- data/lib/solid_objects/executor.rb +7 -1
- data/lib/solid_objects/reminder_scheduler.rb +28 -14
- data/lib/solid_objects/test_helper.rb +16 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +3 -3
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/sig/generated/lib/solid_objects/actor.rbs +2 -2
- data/sig/generated/lib/solid_objects/actor_definition.rbs +9 -2
- data/sig/generated/lib/solid_objects/errors.rbs +3 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +9 -6
- data/sig/generated/lib/solid_objects/test_helper.rbs +3 -0
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/models/solid_objects/broadcast.rbs +2 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- 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
|
|
27
|
-
|
|
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:
|
|
107
|
-
|
|
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
|
|
14
|
-
ERB component. The chat component
|
|
15
|
-
Ruby array, rerenders its `<ol>` after
|
|
16
|
-
|
|
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.
|
|
@@ -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
|
|
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"
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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[
|
|
167
|
-
raise
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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!(
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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: ..
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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(
|
|
@@ -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
|