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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dc01a4c7b7f44d7dbd68b1194abd64a2c5515d5280a1e9cae15b119ff18508c
|
|
4
|
+
data.tar.gz: 68099ea645dff1d976f9aeda77d79d04ed8280e18bfc6d4235daca972ffafad4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 335cec6b8f87328e9a88eee32bbd8aaca5b019cb0ef76dfa70f5efa261afb81a2a034d3bc6a11189f22c4809583d9cce1538c6ecb1ff8cc8319c1d195fec8295
|
|
7
|
+
data.tar.gz: 445c227d5badd737cb44f9b44b87917b73c3e2a55a66acb6a4cd1fe09a39febda314f00188cd35eabc70acef5d5b95503c3cedf5c35632e22948fa62667a62db
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-08-15
|
|
4
|
+
|
|
5
|
+
- **Breaking:** make observables invalidation-only by default. An ordinary
|
|
6
|
+
`observable :status` continues to detect changes and refresh reactive
|
|
7
|
+
components, but persists `{}` and sends no scalar value over Action Cable.
|
|
8
|
+
Declare `observable :status, broadcast: :value` to deliberately store and
|
|
9
|
+
share the projection with every authorized actor subscriber. Applications
|
|
10
|
+
upgrading from 0.12.x must add that opt-in to observables rendered as scalar
|
|
11
|
+
targets.
|
|
12
|
+
- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
|
|
13
|
+
It covers instances and their committed state, the ready and claimed
|
|
14
|
+
mailbox, reminders, effects, broadcasts, dead letters, and processes, with
|
|
15
|
+
actor-type and actor-id filtering, status filters, paging, and a polled
|
|
16
|
+
`GET /stats` endpoint. Mount it with
|
|
17
|
+
`mount SolidObjects::Web => "/solid_objects/dashboard"` after
|
|
18
|
+
`require "solid_objects/web"`; requiring the gem does not load it, so a
|
|
19
|
+
worker process carries no web stack.
|
|
20
|
+
- Authorize every dashboard route through `authorize_administration`. Each
|
|
21
|
+
route declares its own `action` and `resource`, and a route declared without
|
|
22
|
+
a policy raises at load time. The policy receives a context that answers
|
|
23
|
+
`request`, `session`, and `env`.
|
|
24
|
+
- Add two dashboard actions: an idempotent dead letter retry through
|
|
25
|
+
`SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
|
|
26
|
+
clears `paused_at` so the activation manager stops claiming that identity. A
|
|
27
|
+
retry the mailbox refuses, such as an actor class that no longer exists,
|
|
28
|
+
renders the reason with a 422 rather than failing the request.
|
|
29
|
+
- Draw instances per actor type, mailbox depth, and outbox and reminder status
|
|
30
|
+
with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
|
|
31
|
+
host is the only external origin the content security policy names. Point
|
|
32
|
+
`SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
|
|
33
|
+
with no outbound network access, or set it to nil to render without charts.
|
|
34
|
+
- Add `SolidObjects::Web.register` for extension tabs, routes, and view
|
|
35
|
+
directories, and `SolidObjects::Web.use` for Rack middleware in front of the
|
|
36
|
+
dashboard.
|
|
37
|
+
- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
|
|
38
|
+
directory in the gem.
|
|
39
|
+
|
|
40
|
+
## 0.12.1 - 2026-08-13
|
|
41
|
+
|
|
42
|
+
- Add invalidation-only observables with `broadcast: :invalidation`. They still
|
|
43
|
+
detect changes and refresh reactive components, but persist `{}` and send no
|
|
44
|
+
scalar value over Action Cable. Document that ordinary observable values are
|
|
45
|
+
shared with every authorized actor subscriber and that subscriber-specific
|
|
46
|
+
state belongs in a payload projection.
|
|
47
|
+
- **Breaking:** include the originally staged `arguments:` in effect success
|
|
48
|
+
and failure callbacks so actors can correlate concurrent effects.
|
|
49
|
+
- Accept identifier-style rejection codes, including camelCase and symbols,
|
|
50
|
+
and fail malformed codes once with non-retryable
|
|
51
|
+
`SolidObjects::InvalidRejectionCode` diagnostics.
|
|
52
|
+
- Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
|
|
53
|
+
reminder tests without sleeping or mutating runtime rows.
|
|
54
|
+
|
|
3
55
|
## 0.12.0 - 2026-08-13
|
|
4
56
|
|
|
5
57
|
- Replace positional actor dispatch with fluent operation selection. Direct
|
data/README.md
CHANGED
|
@@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See
|
|
|
86
86
|
- [State migrations](#state-migrations)
|
|
87
87
|
- [Configuration](#configuration)
|
|
88
88
|
- [Workers and operations](#workers-and-operations)
|
|
89
|
+
- [Dashboard](#dashboard)
|
|
89
90
|
- [Database support](#database-support)
|
|
90
91
|
- [Guarantees](#guarantees)
|
|
91
92
|
- [When to use it](#when-to-use-it)
|
|
@@ -178,7 +179,7 @@ class ChatRoom < SolidObjects::Actor
|
|
|
178
179
|
attribute :recent_messages, default: -> { [] }
|
|
179
180
|
attribute :status, default: "open"
|
|
180
181
|
|
|
181
|
-
observable :message_count do
|
|
182
|
+
observable :message_count, broadcast: :value do
|
|
182
183
|
recent_messages.length
|
|
183
184
|
end
|
|
184
185
|
|
|
@@ -205,6 +206,23 @@ dependencies changes:
|
|
|
205
206
|
<% end %>
|
|
206
207
|
```
|
|
207
208
|
|
|
209
|
+
Observables are invalidation-only by default. Their values remain available to
|
|
210
|
+
authorized component rendering, while durable rows and Action Cable frames
|
|
211
|
+
carry only change metadata. Explicitly opt a scalar observable into sharing its
|
|
212
|
+
value with every authorized actor subscriber:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
observable :message_count, broadcast: :value do
|
|
216
|
+
recent_messages.length
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Only `broadcast: :value` observables can render as scalar `<span>` targets.
|
|
221
|
+
Their changed values are stored in `solid_objects_broadcasts` and can reach
|
|
222
|
+
every subscriber that passes `authorize_subscription` for the actor. Put
|
|
223
|
+
per-viewer state in `broadcast_payload`, which computes a fresh projection for
|
|
224
|
+
each connection.
|
|
225
|
+
|
|
208
226
|
Component names can repeat when each instance has a stable key. Signed
|
|
209
227
|
JSON-compatible locals let one conventional partial render the matching
|
|
210
228
|
projection:
|
|
@@ -515,7 +533,7 @@ class ShoppingCart < SolidObjects::Actor
|
|
|
515
533
|
end
|
|
516
534
|
end
|
|
517
535
|
|
|
518
|
-
observable :items_count do
|
|
536
|
+
observable :items_count, broadcast: :value do
|
|
519
537
|
items.sum { |item| item.fetch("quantity") }
|
|
520
538
|
end
|
|
521
539
|
end
|
|
@@ -736,8 +754,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
|
|
|
736
754
|
state is rolled back, and no later mailbox turn is blocked.
|
|
737
755
|
|
|
738
756
|
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
|
|
739
|
-
match `\A[
|
|
740
|
-
|
|
757
|
+
match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
|
|
758
|
+
`SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
|
|
741
759
|
|
|
742
760
|
### Redelivery
|
|
743
761
|
|
|
@@ -819,11 +837,11 @@ def checkout(payment_id:, amount_cents:)
|
|
|
819
837
|
)
|
|
820
838
|
end
|
|
821
839
|
|
|
822
|
-
def payment_succeeded(effect_id:, result:)
|
|
840
|
+
def payment_succeeded(effect_id:, arguments:, result:)
|
|
823
841
|
self.checkout_status = "paid"
|
|
824
842
|
end
|
|
825
843
|
|
|
826
|
-
def payment_failed(effect_id:, error:)
|
|
844
|
+
def payment_failed(effect_id:, arguments:, error:)
|
|
827
845
|
self.checkout_status = "failed"
|
|
828
846
|
end
|
|
829
847
|
```
|
|
@@ -842,6 +860,10 @@ end
|
|
|
842
860
|
|
|
843
861
|
The provider call can repeat if a process dies after external success but
|
|
844
862
|
before recording completion. The stable effect ID is the idempotency key.
|
|
863
|
+
Success callbacks receive `effect_id:`, the originally staged `arguments:`,
|
|
864
|
+
and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
|
|
865
|
+
`error:`, so an actor can correlate concurrent effects without storing a
|
|
866
|
+
separate callback ledger.
|
|
845
867
|
|
|
846
868
|
## Reminders
|
|
847
869
|
|
|
@@ -1063,6 +1085,50 @@ an initializer.
|
|
|
1063
1085
|
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
|
|
1064
1086
|
shutdown, retention, and backup guidance.
|
|
1065
1087
|
|
|
1088
|
+
## Dashboard
|
|
1089
|
+
|
|
1090
|
+
`SolidObjects::Web` is a Rack application that shows instances and their state,
|
|
1091
|
+
the mailbox, reminders, effects, broadcasts, dead letters, and the registered
|
|
1092
|
+
processes. Mount it inside the application routes, so the Rails session
|
|
1093
|
+
middleware runs first:
|
|
1094
|
+
|
|
1095
|
+
```ruby
|
|
1096
|
+
# config/routes.rb
|
|
1097
|
+
require "solid_objects/web"
|
|
1098
|
+
|
|
1099
|
+
Rails.application.routes.draw do
|
|
1100
|
+
mount SolidObjects::Web => "/solid_objects/dashboard"
|
|
1101
|
+
end
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
It is not loaded by `require "solid_objects"`: a worker process must not carry
|
|
1105
|
+
a web stack. The dashboard and the engine are separate mounts, so an
|
|
1106
|
+
application that uses reactive ERB mounts both on different paths.
|
|
1107
|
+
|
|
1108
|
+
Every page asks `authorize_administration` before its handler runs, and that
|
|
1109
|
+
policy denies by default, so a mount alone exposes nothing. The block receives
|
|
1110
|
+
the route's own `action:` and `resource:`, and an `authorization_context:` that
|
|
1111
|
+
answers `request`, `session`, and `env`.
|
|
1112
|
+
|
|
1113
|
+
The dashboard changes only two things. Retrying a dead letter goes through
|
|
1114
|
+
`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
|
|
1115
|
+
sets `paused_at` so the activation manager stops claiming that identity; a pass
|
|
1116
|
+
already in flight finishes its turn, and a synchronous caller waiting on a
|
|
1117
|
+
paused instance times out rather than receiving a result.
|
|
1118
|
+
|
|
1119
|
+
The dashboard draws instances per actor type, mailbox depth, and outbox status
|
|
1120
|
+
with Chart.js, loaded from a CDN with a subresource integrity hash. A
|
|
1121
|
+
deployment with no outbound network access can vendor the file, or turn the
|
|
1122
|
+
charts off:
|
|
1123
|
+
|
|
1124
|
+
```ruby
|
|
1125
|
+
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
|
|
1126
|
+
SolidObjects::Web.chart_library_integrity = nil
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
Read the [dashboard guide](docs/dashboard.md) for the full policy table,
|
|
1130
|
+
extension registration, and query cost.
|
|
1131
|
+
|
|
1066
1132
|
## Database support
|
|
1067
1133
|
|
|
1068
1134
|
Solid Objects supports:
|
|
@@ -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
|
|
@@ -9,7 +9,11 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
|
|
|
9
9
|
|
|
10
10
|
## Decision
|
|
11
11
|
|
|
12
|
-
The executor evaluates declared observables before and after a successful
|
|
12
|
+
The executor evaluates declared observables before and after a successful
|
|
13
|
+
message. Changes create invalidation-only broadcast outbox records inside the
|
|
14
|
+
message commit by default. An explicit `broadcast: :value` declaration stores
|
|
15
|
+
the projection and lets a broadcast worker deliver scalar Turbo Stream
|
|
16
|
+
replacements after commit.
|
|
13
17
|
|
|
14
18
|
One `solid_object` block creates one signed Action Cable subscription and
|
|
15
19
|
contains stable targets for multiple observables and components. Component
|
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,9 @@ 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
|
|
172
|
+
updates. Its durable broadcast row stores only an empty invalidation marker by
|
|
173
|
+
default. `broadcast: :value` explicitly opts into storing and sharing the
|
|
174
|
+
projected value.
|
|
173
175
|
|
|
174
176
|
Lifecycle hooks are deterministic local hooks:
|
|
175
177
|
|
data/docs/authorization.md
CHANGED
|
@@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary.
|
|
|
13
13
|
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
18
|
Waiting again through `MessageReference#wait` reauthorizes the stored
|
|
19
19
|
invocation as a message or query. Internal reminder, effect-callback, and
|
|
@@ -53,9 +53,9 @@ Component partials receive the resolved value as the
|
|
|
53
53
|
`authorization_context` local, allowing two authorized viewers to render
|
|
54
54
|
different projections. Responses use `Cache-Control: private, no-store`.
|
|
55
55
|
Durable outbox rows and shared Cable messages never contain component HTML.
|
|
56
|
-
The stream token also signs
|
|
57
|
-
specific scope.
|
|
58
|
-
their state value to the browser.
|
|
56
|
+
The stream token also signs explicitly value-broadcast scalar observable
|
|
57
|
+
targets rendered into that specific scope. Default component dependencies send
|
|
58
|
+
invalidation metadata but not their state value to the browser.
|
|
59
59
|
|
|
60
60
|
Keyed components sign their `component_key` and declared JSON-compatible
|
|
61
61
|
locals into the component token. Initial rendering and every refresh pass those
|
|
@@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
|
154
154
|
end
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
That policy also denies every `SolidObjects::Web` page, which is the correct
|
|
158
|
+
result for a host whose only administration boundary is shell access. A policy
|
|
159
|
+
that opens the dashboard should separate reading from writing, because
|
|
160
|
+
`action` distinguishes them: `index` and `show` read, while `pause`, `resume`,
|
|
161
|
+
and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the
|
|
162
|
+
action and resource of every page.
|
|
163
|
+
|
|
157
164
|
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
|
|
158
165
|
probe is deliberately conservative: a context-aware policy may correctly warn
|
|
159
166
|
because it denies a `nil` context.
|
data/docs/correctness.md
CHANGED
|
@@ -131,9 +131,9 @@ rendering invokes query authorization, Cable separately invokes subscription
|
|
|
131
131
|
authorization, and the cookie-bearing refresh request invokes query
|
|
132
132
|
authorization again for the component name and every dependency.
|
|
133
133
|
|
|
134
|
-
The actor stream token separately signs the
|
|
135
|
-
into its scope. A component dependency
|
|
136
|
-
its name and revision over Cable, not its serialized value.
|
|
134
|
+
The actor stream token separately signs the explicitly value-broadcast scalar
|
|
135
|
+
observable targets rendered into its scope. A default component dependency
|
|
136
|
+
carries only its name and revision over Cable, not its serialized value.
|
|
137
137
|
|
|
138
138
|
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
|
|
139
139
|
changed by the same turn, and ignores an older pair after a newer one. A new
|
data/docs/dashboard.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Operator dashboard
|
|
2
|
+
|
|
3
|
+
`SolidObjects::Web` is a Rack application that shows what the actor runtime is
|
|
4
|
+
doing: instances and their state, the mailbox, reminders, effects, broadcasts,
|
|
5
|
+
dead letters, and the registered processes. It reads the same tables the
|
|
6
|
+
runtime writes, so it needs no separate store and no agent.
|
|
7
|
+
|
|
8
|
+
It is deliberately not loaded by `require "solid_objects"`. A worker process
|
|
9
|
+
must not carry a web stack, and an application that never mounts the dashboard
|
|
10
|
+
must not pay for it.
|
|
11
|
+
|
|
12
|
+
## Mounting
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# config/routes.rb
|
|
16
|
+
require "solid_objects/web"
|
|
17
|
+
|
|
18
|
+
Rails.application.routes.draw do
|
|
19
|
+
mount SolidObjects::Web => "/solid_objects/dashboard"
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Mount it inside the application routes so the Rails session middleware runs
|
|
24
|
+
first. The dashboard needs a Rack session for CSRF protection and refuses a
|
|
25
|
+
state changing request without one.
|
|
26
|
+
|
|
27
|
+
The dashboard and the engine are separate mounts. Mount the engine as well if
|
|
28
|
+
the application uses reactive ERB, and give each one its own path:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
mount SolidObjects::Engine => "/solid_objects"
|
|
32
|
+
mount SolidObjects::Web => "/solid_objects/dashboard"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
In a bare Rack application, supply the session middleware yourself:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
|
|
39
|
+
run SolidObjects::Web
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Authorization
|
|
43
|
+
|
|
44
|
+
Every page asks `configuration.authorize_administration` before its handler
|
|
45
|
+
runs. That policy denies by default, so a mount alone exposes nothing. A route
|
|
46
|
+
declared without a policy raises at load time, which is why a new page cannot
|
|
47
|
+
reach the actor tables before an application has said who may read it.
|
|
48
|
+
|
|
49
|
+
The block receives the route's own action and resource:
|
|
50
|
+
|
|
51
|
+
| Page | `action` | `resource` | `resource_id` |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none |
|
|
54
|
+
| Instance list | `index` | `instances` | none |
|
|
55
|
+
| Instance detail | `show` | `instances` | instance id |
|
|
56
|
+
| Pause an instance | `pause` | `instances` | instance id |
|
|
57
|
+
| Resume an instance | `resume` | `instances` | instance id |
|
|
58
|
+
| Mailbox | `index` | `messages` | none |
|
|
59
|
+
| Message detail | `show` | `messages` | message id |
|
|
60
|
+
| Reminders | `index` | `reminders` | none |
|
|
61
|
+
| Effects | `index` | `effects` | none |
|
|
62
|
+
| Broadcasts | `index` | `broadcasts` | none |
|
|
63
|
+
| Dead letter list | `index` | `dead_letters` | none |
|
|
64
|
+
| Dead letter detail | `show` | `dead_letters` | dead letter id |
|
|
65
|
+
| Retry a dead letter | `retry` | `dead_letters` | dead letter id |
|
|
66
|
+
| Processes | `index` | `processes` | none |
|
|
67
|
+
|
|
68
|
+
`authorization_context:` is the request object. It answers `request`,
|
|
69
|
+
`session`, and `env`, so a policy can read the signed-in operator the same way
|
|
70
|
+
a controller does:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
SolidObjects.configure do |configuration|
|
|
74
|
+
configuration.authorize_administration = lambda do |action:, authorization_context:, **|
|
|
75
|
+
return false unless authorization_context.respond_to?(:session)
|
|
76
|
+
|
|
77
|
+
operator = Operator.find_by(id: authorization_context.session[:operator_id])
|
|
78
|
+
return false unless operator&.administrator?
|
|
79
|
+
|
|
80
|
+
action == "index" || action == "show" || operator.may_write_runtime?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The command line reaches the same policy with `{ source: "cli" }` rather than
|
|
86
|
+
a request, which is why the example checks what the context answers before
|
|
87
|
+
reading a session from it.
|
|
88
|
+
|
|
89
|
+
## Pages
|
|
90
|
+
|
|
91
|
+
**Dashboard.** Totals per subsystem, the registered processes, and the most
|
|
92
|
+
recent dead letters. The summary bar appears on every page and can poll
|
|
93
|
+
`GET /stats` for the same numbers; nothing else on the page refreshes, because
|
|
94
|
+
a table that reloads under an operator who is reading it is worse than a stale
|
|
95
|
+
one.
|
|
96
|
+
|
|
97
|
+
**Instances.** Filter by actor type and by an actor id substring. Each row
|
|
98
|
+
shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail
|
|
99
|
+
page shows committed state, the ready and claimed mailbox, message history,
|
|
100
|
+
reminders, effects, broadcasts, and dead letters for that identity.
|
|
101
|
+
|
|
102
|
+
**Mailbox.** The ready and claimed messages across every identity, oldest
|
|
103
|
+
first. Mailbox lag on the summary bar is the age of the oldest message that is
|
|
104
|
+
already due, which is how far behind the workers are.
|
|
105
|
+
|
|
106
|
+
**Reminders, effects, broadcasts, processes.** Status filtered lists.
|
|
107
|
+
|
|
108
|
+
## Charts
|
|
109
|
+
|
|
110
|
+
The dashboard draws three charts: instances per actor type, mailbox depth, and
|
|
111
|
+
a stacked view of effects, broadcasts, and reminders by status. Each canvas
|
|
112
|
+
carries its own numbers in a `data-chart-values` attribute, so the page needs
|
|
113
|
+
no inline script and no request to draw. Mailbox depth and the status chart
|
|
114
|
+
redraw when the Live poller reports new totals, because `/stats` already
|
|
115
|
+
carries those numbers. The instance chart does not: `/stats` does not group by
|
|
116
|
+
actor type, and adding that would put a `GROUP BY` on every poll.
|
|
117
|
+
|
|
118
|
+
Chart.js comes from a CDN with a subresource integrity hash, so a compromised
|
|
119
|
+
CDN cannot substitute other code, and the CDN host is the only external origin
|
|
120
|
+
the content security policy names.
|
|
121
|
+
|
|
122
|
+
A deployment with no outbound network access should vendor the file:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
|
|
126
|
+
SolidObjects::Web.chart_library_integrity = nil
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A path below the mount is served from the dashboard's own asset directory and
|
|
130
|
+
needs no policy exception. Setting the URL to `nil` renders the dashboard
|
|
131
|
+
without charts and names no external origin at all.
|
|
132
|
+
|
|
133
|
+
Set these before the first request. The middleware stack and the compiled
|
|
134
|
+
templates are built once and cached.
|
|
135
|
+
|
|
136
|
+
**Dead letters.** The exception, its message, and its backtrace, with a retry
|
|
137
|
+
button.
|
|
138
|
+
|
|
139
|
+
## Actions
|
|
140
|
+
|
|
141
|
+
The dashboard changes only two things.
|
|
142
|
+
|
|
143
|
+
**Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which
|
|
144
|
+
enqueues the original operation under an idempotency key. Pressing it twice
|
|
145
|
+
produces one message rather than two.
|
|
146
|
+
|
|
147
|
+
A retry re-enters the mailbox, which refuses work the runtime cannot accept: an
|
|
148
|
+
actor class that no longer exists, a full mailbox, a payload over the cap. The
|
|
149
|
+
dashboard renders the dead letter again with the reason and a 422 status,
|
|
150
|
+
rather than failing the request.
|
|
151
|
+
|
|
152
|
+
**Pause an instance** sets `paused_at`, and the activation manager stops
|
|
153
|
+
claiming that identity. Two consequences matter:
|
|
154
|
+
|
|
155
|
+
- A pass already in flight finishes its turn. Pause is not a stop.
|
|
156
|
+
- A synchronous caller waiting on a paused instance times out rather than
|
|
157
|
+
receiving a result, because nothing will execute its message.
|
|
158
|
+
|
|
159
|
+
Resume clears the column and the mailbox drains in sequence order.
|
|
160
|
+
|
|
161
|
+
## Extensions
|
|
162
|
+
|
|
163
|
+
An extension adds pages by declaring routes on the application class. Its
|
|
164
|
+
routes carry an authorization policy like every other route:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
module Tenants
|
|
168
|
+
def self.registered(application)
|
|
169
|
+
application.get "/tenants", policy: { action: "index", resource: "tenants" } do
|
|
170
|
+
@tenants = Tenant.order(:name)
|
|
171
|
+
erb(:tenants)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
SolidObjects::Web.register(
|
|
177
|
+
Tenants,
|
|
178
|
+
tab: "Tenants",
|
|
179
|
+
path: "/tenants",
|
|
180
|
+
views: File.expand_path("../web/views", __dir__)
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A registered view directory is searched before the packaged one, so an
|
|
185
|
+
application can replace a single page without forking the gem. A template
|
|
186
|
+
reads its arguments from `locals`, and a replacement `layout.erb` renders the
|
|
187
|
+
page it wraps with `locals.fetch(:content)`.
|
|
188
|
+
|
|
189
|
+
Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`,
|
|
190
|
+
for example to require HTTP basic authentication in an environment that has no
|
|
191
|
+
session-backed operator.
|
|
192
|
+
|
|
193
|
+
## Cost
|
|
194
|
+
|
|
195
|
+
The summary bar issues one grouped count per subsystem on every page, and each
|
|
196
|
+
list page counts its own relation to page it. That is a fixed set of indexed
|
|
197
|
+
aggregate queries, not a scan proportional to actor traffic, but it is not
|
|
198
|
+
free: do not put the dashboard behind an uptime monitor that loads the whole
|
|
199
|
+
page on an interval. `HEAD /` exists for that. It touches one table and
|
|
200
|
+
returns no body.
|
data/docs/database-schema.md
CHANGED
|
@@ -92,10 +92,11 @@ 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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
duplicate rows for one actor turn. Rows contain `{}` for the default
|
|
96
|
+
invalidation-only observable, or the observable JSON value after an explicit
|
|
97
|
+
`broadcast: :value` opt-in, plus message/instance references used to derive
|
|
98
|
+
invalidation metadata, never personalized rendered HTML. Claim and delivery
|
|
99
|
+
indexes support retries and cleanup.
|
|
99
100
|
|
|
100
101
|
### `dead_letters`
|
|
101
102
|
|
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
|
@@ -13,12 +13,19 @@
|
|
|
13
13
|
<% end %>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Every observable gets a stable opaque DOM ID. Multiple values
|
|
17
|
-
actor subscription and Action Cable multiplexes actor
|
|
18
|
-
browser's physical WebSocket.
|
|
16
|
+
Every value-broadcast observable gets a stable opaque DOM ID. Multiple values
|
|
17
|
+
share the one actor subscription and Action Cable multiplexes actor
|
|
18
|
+
subscriptions over the browser's physical WebSocket.
|
|
19
19
|
|
|
20
20
|
Scalar observable calls such as `cart.items_count` render stable `<span>`
|
|
21
|
-
targets. Their broadcast remains a direct escaped text replacement
|
|
21
|
+
targets. Their broadcast remains a direct escaped text replacement, and their
|
|
22
|
+
declaration must explicitly opt in with `broadcast: :value`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
observable :items_count, broadcast: :value do
|
|
26
|
+
items.sum { |item| item.fetch("quantity") }
|
|
27
|
+
end
|
|
28
|
+
```
|
|
22
29
|
|
|
23
30
|
A reactive component declares one or more explicit observable dependencies.
|
|
24
31
|
`actor.component(:summary, observes: ...)` resolves the host partial by
|
|
@@ -65,6 +72,27 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
|
|
|
65
72
|
invalidation correct and prevents a partial from silently depending on state
|
|
66
73
|
that cannot wake it.
|
|
67
74
|
|
|
75
|
+
Observables are invalidation-only by default. Their values never enter the
|
|
76
|
+
durable outbox or Action Cable frame, so the ordinary declaration is the safe
|
|
77
|
+
choice for component dependencies:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
observable :player_one do
|
|
81
|
+
player_in_seat(1)
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The runtime still compares the value around each successful turn and uses a
|
|
86
|
+
change to refresh components, but stores `{}` and renders no scalar Turbo
|
|
87
|
+
replacement. An invalidation-only observable therefore cannot be used as a
|
|
88
|
+
scalar value such as `actor.player_one`. The component endpoint reads the
|
|
89
|
+
latest committed value and authorizes it again; subscriber-specific state
|
|
90
|
+
belongs in `broadcast_payload`.
|
|
91
|
+
|
|
92
|
+
Use `broadcast: :value` only for a shared projection that may be stored and
|
|
93
|
+
sent to every subscriber that passes `authorize_subscription`. Authorization
|
|
94
|
+
to the actor stream is not a per-viewer projection.
|
|
95
|
+
|
|
68
96
|
```erb
|
|
69
97
|
<ul>
|
|
70
98
|
<% actor.recent_messages.each do |message| %>
|
|
@@ -416,7 +444,9 @@ component key, locals, or DOM ID.
|
|
|
416
444
|
## Broadcast durability
|
|
417
445
|
|
|
418
446
|
The actor's fenced commit compares observables before and after the turn and
|
|
419
|
-
inserts one broadcast row per changed
|
|
447
|
+
inserts one broadcast row per changed observable. Invalidation-only
|
|
448
|
+
observables, the default, store `{}`. Observables declared with
|
|
449
|
+
`broadcast: :value` store the changed JSON value. The actor state, monotonic
|
|
420
450
|
`state_revision`, message completion, and broadcast rows commit atomically. A
|
|
421
451
|
rolled-back or fenced-out turn therefore cannot invalidate a component.
|
|
422
452
|
|
|
@@ -456,8 +486,8 @@ response revision fencing.
|
|
|
456
486
|
## Cost model
|
|
457
487
|
|
|
458
488
|
The durable row cost is unchanged: one broadcast row per changed observable,
|
|
459
|
-
containing its JSON value
|
|
460
|
-
invalidation metadata. No rendered document is stored. Each affected component
|
|
489
|
+
containing either its JSON value or an empty invalidation marker plus the
|
|
490
|
+
message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
|
|
461
491
|
adds one authorized GET and one partial render per non-coalesced state
|
|
462
492
|
revision. A repeated keyed component adds one GET and render per key. Signed
|
|
463
493
|
locals increase page and Cable subscription bytes but do not create durable
|