solid_objects 0.12.1 → 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 +37 -0
- data/README.md +58 -11
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +3 -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/realtime.md +22 -14
- data/docs/roadmap.md +24 -6
- data/docs/security.md +7 -7
- 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 +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- 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/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/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,42 @@
|
|
|
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
|
+
|
|
3
40
|
## 0.12.1 - 2026-08-13
|
|
4
41
|
|
|
5
42
|
- Add invalidation-only observables with `broadcast: :invalidation`. They still
|
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,20 +206,22 @@ dependencies changes:
|
|
|
205
206
|
<% end %>
|
|
206
207
|
```
|
|
207
208
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
211
213
|
|
|
212
214
|
```ruby
|
|
213
|
-
observable :
|
|
214
|
-
|
|
215
|
+
observable :message_count, broadcast: :value do
|
|
216
|
+
recent_messages.length
|
|
215
217
|
end
|
|
216
218
|
```
|
|
217
219
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
which computes a fresh projection for
|
|
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.
|
|
222
225
|
|
|
223
226
|
Component names can repeat when each instance has a stable key. Signed
|
|
224
227
|
JSON-compatible locals let one conventional partial render the matching
|
|
@@ -530,7 +533,7 @@ class ShoppingCart < SolidObjects::Actor
|
|
|
530
533
|
end
|
|
531
534
|
end
|
|
532
535
|
|
|
533
|
-
observable :items_count do
|
|
536
|
+
observable :items_count, broadcast: :value do
|
|
534
537
|
items.sum { |item| item.fetch("quantity") }
|
|
535
538
|
end
|
|
536
539
|
end
|
|
@@ -1082,6 +1085,50 @@ an initializer.
|
|
|
1082
1085
|
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
|
|
1083
1086
|
shutdown, retention, and backup guidance.
|
|
1084
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
|
+
|
|
1085
1132
|
## Database support
|
|
1086
1133
|
|
|
1087
1134
|
Solid Objects supports:
|
|
@@ -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
|
@@ -169,8 +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. Its durable broadcast row stores
|
|
173
|
-
`broadcast: :
|
|
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.
|
|
174
175
|
|
|
175
176
|
Lifecycle hooks are deterministic local hooks:
|
|
176
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/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,16 +72,12 @@ 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
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
actor stream is not a per-viewer projection.
|
|
72
|
-
|
|
73
|
-
For a component dependency whose value must never enter the durable outbox or
|
|
74
|
-
Action Cable frame, declare it invalidation-only:
|
|
75
|
+
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:
|
|
75
78
|
|
|
76
79
|
```ruby
|
|
77
|
-
observable :player_one
|
|
80
|
+
observable :player_one do
|
|
78
81
|
player_in_seat(1)
|
|
79
82
|
end
|
|
80
83
|
```
|
|
@@ -86,6 +89,10 @@ scalar value such as `actor.player_one`. The component endpoint reads the
|
|
|
86
89
|
latest committed value and authorizes it again; subscriber-specific state
|
|
87
90
|
belongs in `broadcast_payload`.
|
|
88
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
|
+
|
|
89
96
|
```erb
|
|
90
97
|
<ul>
|
|
91
98
|
<% actor.recent_messages.each do |message| %>
|
|
@@ -437,8 +444,9 @@ component key, locals, or DOM ID.
|
|
|
437
444
|
## Broadcast durability
|
|
438
445
|
|
|
439
446
|
The actor's fenced commit compares observables before and after the turn and
|
|
440
|
-
inserts one broadcast row per changed observable.
|
|
441
|
-
|
|
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
|
|
442
450
|
`state_revision`, message completion, and broadcast rows commit atomically. A
|
|
443
451
|
rolled-back or fenced-out turn therefore cannot invalidate a component.
|
|
444
452
|
|
data/docs/roadmap.md
CHANGED
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
listed here while broken in that worker: the scheduler reached a constant the
|
|
25
25
|
caller path happened to load, so reminders never fired in production and
|
|
26
26
|
every in-process test still passed
|
|
27
|
-
- Durable
|
|
28
|
-
replacement, keyed ERB components, signed
|
|
29
|
-
replace or morph refresh.
|
|
30
|
-
change detection while storing and broadcasting
|
|
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
|
|
31
32
|
- Batched component refreshes: components sharing a signed `batch:` collapse to
|
|
32
33
|
one browser request per revision, served as HTML frames in a JSON envelope
|
|
33
34
|
- Personalized state payload broadcasts computed per subscriber under that
|
|
@@ -107,8 +108,25 @@
|
|
|
107
108
|
handing the block a raw Cable connection.
|
|
108
109
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
109
110
|
distributed per-actor rate limits and global admission control do not.
|
|
110
|
-
- Administration:
|
|
111
|
-
|
|
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.
|
|
112
130
|
|
|
113
131
|
## Next milestones
|
|
114
132
|
|
data/docs/security.md
CHANGED
|
@@ -36,13 +36,13 @@ 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
must be computed separately for each authorized connection.
|
|
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
46
|
|
|
47
47
|
## Serialization
|
|
48
48
|
|
|
@@ -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
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -53,7 +53,7 @@ module SolidObjects
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
|
|
56
|
-
def observable(name, broadcast: :
|
|
56
|
+
def observable(name, broadcast: :invalidation, &block)
|
|
57
57
|
definition.add_observable(name, block, broadcast:)
|
|
58
58
|
end
|
|
59
59
|
|