solid_objects 0.3.0 → 0.4.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 +10 -0
- data/README.md +134 -25
- data/app/controllers/solid_objects/components_controller.rb +78 -0
- data/app/helpers/solid_objects/actor_helper.rb +13 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
- data/docs/architecture.md +61 -15
- data/docs/authorization.md +38 -1
- data/docs/correctness.md +28 -2
- data/docs/database-schema.md +13 -5
- data/docs/realtime.md +106 -12
- data/docs/roadmap.md +6 -5
- data/examples/application/README.md +5 -4
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
- data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
- data/examples/application/config/initializers/solid_objects.rb +9 -3
- data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
- data/lib/solid_objects/actor.rb +11 -0
- data/lib/solid_objects/actor_channel.rb +73 -5
- data/lib/solid_objects/actor_snapshot.rb +25 -6
- data/lib/solid_objects/actor_view.rb +103 -10
- data/lib/solid_objects/component_path_resolver.rb +27 -0
- data/lib/solid_objects/component_registration.rb +117 -0
- data/lib/solid_objects/component_renderer.rb +82 -0
- data/lib/solid_objects/component_subscriptions.rb +109 -0
- data/lib/solid_objects/component_token.rb +139 -0
- data/lib/solid_objects/component_view.rb +67 -0
- data/lib/solid_objects/configuration.rb +12 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
- data/lib/solid_objects/errors.rb +12 -0
- data/lib/solid_objects/executor.rb +1 -0
- data/lib/solid_objects/stream_token.rb +32 -13
- data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +6 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
- data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
- data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
- data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
- data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
- data/sig/generated/lib/solid_objects/errors.rbs +12 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 557108141818714af50bfee19acda22c9243482e976b50329e5e234aed6d9adb
|
|
4
|
+
data.tar.gz: fe525288022e4bb0630e60bc7ec949e2f61d1ad152fadcaccaeaa41bd1a0376e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4414b1f2e0d47f95f9307fec6140894bcd8ebcab6dbdc20f54155c332dea76de2728ca7b0bf71bc13c978f2494e059f2099986757c3b933296d460a2bec7a65
|
|
7
|
+
data.tar.gz: 6e807551d58c7d87465bc55b460386f5b9617e36021ddbdfdcbfd25aac31d9c48a7166a818f5a39be3f52eb6a7cb63efd74223a0512fb7a53dce7c55a2c23ca8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Add dependency-driven live ERB components with request-time authorization,
|
|
6
|
+
conventional partial resolution, revision fencing, refresh coalescing, and
|
|
7
|
+
reconnect convergence without broadcasting personalized HTML.
|
|
8
|
+
- Persist a monotonic state revision for secure component refresh ordering.
|
|
9
|
+
- Retry SQLite synchronous lock contention in Ruby so a native busy wait
|
|
10
|
+
cannot starve the thread holding the database lock.
|
|
11
|
+
- Allow maintainers to dispatch CI manually when a push webhook is dropped.
|
|
12
|
+
|
|
3
13
|
## 0.3.0 - 2026-08-06
|
|
4
14
|
|
|
5
15
|
- Reject application-record writes from actor handlers and provide registered
|
data/README.md
CHANGED
|
@@ -74,6 +74,7 @@ tested, but the project does not yet claim production readiness. See
|
|
|
74
74
|
- [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
|
|
75
75
|
- [Reactive ERB](#reactive-erb)
|
|
76
76
|
- [Installation](#installation)
|
|
77
|
+
- [Upgrading](#upgrading)
|
|
77
78
|
- [Worker requirements](#worker-requirements)
|
|
78
79
|
- [Defining an actor](#defining-an-actor)
|
|
79
80
|
- [Actor identity](#actor-identity)
|
|
@@ -173,42 +174,105 @@ rolled-back state change cannot leak into the page.
|
|
|
173
174
|
Define an observable:
|
|
174
175
|
|
|
175
176
|
```ruby
|
|
176
|
-
class
|
|
177
|
-
attribute :
|
|
177
|
+
class ChatRoom < SolidObjects::Actor
|
|
178
|
+
attribute :recent_messages, default: -> { [] }
|
|
179
|
+
attribute :status, default: "open"
|
|
178
180
|
|
|
179
|
-
observable :
|
|
180
|
-
|
|
181
|
+
observable :message_count do
|
|
182
|
+
recent_messages.length
|
|
181
183
|
end
|
|
184
|
+
|
|
185
|
+
observable :recent_messages
|
|
186
|
+
observable :status
|
|
182
187
|
end
|
|
183
188
|
```
|
|
184
189
|
|
|
185
|
-
|
|
190
|
+
Scalar observables remain stable `<span>` targets:
|
|
191
|
+
|
|
192
|
+
```erb
|
|
193
|
+
<%= solid_object @room, authorization_context: current_user do |room| %>
|
|
194
|
+
Messages: <%= room.message_count %>
|
|
195
|
+
<% end %>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Reactive components rerender a host ERB partial when one of their explicit
|
|
199
|
+
dependencies changes:
|
|
186
200
|
|
|
187
201
|
```erb
|
|
188
|
-
<%= solid_object
|
|
189
|
-
|
|
202
|
+
<%= solid_object @room, authorization_context: current_user do |room| %>
|
|
203
|
+
<%= room.component :messages, observes: :recent_messages %>
|
|
204
|
+
<%= room.component :presence, observes: %i[recent_messages status] %>
|
|
190
205
|
<% end %>
|
|
191
206
|
```
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
208
|
+
`room.component(:messages)` resolves only
|
|
209
|
+
`actors/chat_room/_messages`. Its partial receives `actor` and
|
|
210
|
+
`authorization_context` locals:
|
|
211
|
+
|
|
212
|
+
```erb
|
|
213
|
+
<ul>
|
|
214
|
+
<% actor.recent_messages.each do |message| %>
|
|
215
|
+
<li><%= message.fetch("body") %></li>
|
|
216
|
+
<% end %>
|
|
217
|
+
</ul>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Declared observables are deeply frozen ordinary Ruby values inside a
|
|
221
|
+
component. Arrays support loops, hashes support ordinary lookup, conditionals
|
|
222
|
+
work normally, and ERB still escapes user strings. A reactive component cannot
|
|
223
|
+
read `actor.state`, access an undeclared observable, or choose a dynamic
|
|
224
|
+
partial path.
|
|
225
|
+
|
|
226
|
+
That template provides initial server rendering, stable opaque DOM targets,
|
|
227
|
+
and live updates after committed actor turns. One `solid_object` block makes
|
|
228
|
+
one Action Cable subscription for all scalar values and components inside it,
|
|
229
|
+
and Action Cable multiplexes subscriptions over the browser's WebSocket.
|
|
197
230
|
|
|
198
231
|
No client-side state store, custom Stimulus controller, channel class, manual
|
|
199
232
|
broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
|
|
200
|
-
protect integrity,
|
|
201
|
-
|
|
202
|
-
refresh
|
|
233
|
+
protect integrity, not access. Initial rendering authorizes with the
|
|
234
|
+
`authorization_context` passed to `solid_object`; Cable authorizes with its
|
|
235
|
+
connection; every component refresh authorizes again with a request-specific
|
|
236
|
+
context:
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
238
|
+
```ruby
|
|
239
|
+
SolidObjects.configure do |configuration|
|
|
240
|
+
configuration.component_authorization_context = ->(controller:) { Current.user }
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The durable outbox stores one row per changed observable, never personalized
|
|
245
|
+
HTML. Cable sends invalidation metadata over the shared actor stream, then a
|
|
246
|
+
Turbo Frame requests the component with normal cookies. Only scalar targets
|
|
247
|
+
that the server rendered into this `solid_object` scope are signed into its
|
|
248
|
+
stream token and receive value payloads; component-only dependencies do not
|
|
249
|
+
send their values to the browser. The endpoint renders the latest committed
|
|
250
|
+
snapshot, returns `private, no-store`, and reauthorizes the component name plus
|
|
251
|
+
every declared dependency. Two viewers can therefore receive different HTML
|
|
252
|
+
for the same actor without sharing either projection.
|
|
253
|
+
|
|
254
|
+
Reconnect compares the component's signed initial revision with the latest
|
|
255
|
+
actor incarnation and state revision, then refreshes stale components. Cable
|
|
256
|
+
coalesces several dependency changes from one actor turn into one component
|
|
257
|
+
refresh and ignores older out-of-order invalidations. A newer invalidation
|
|
258
|
+
replaces an in-flight frame, so its detached older response cannot overwrite
|
|
259
|
+
newer state.
|
|
260
|
+
|
|
261
|
+
Reactive components add no HTML to durable rows, but each affected component
|
|
262
|
+
causes an authorized HTTP render. One actor turn still inserts one broadcast
|
|
263
|
+
row per changed observable; several dependencies from that turn coalesce at
|
|
264
|
+
the subscriber. Keep components bounded, declare only necessary dependencies,
|
|
265
|
+
and use scalar observables for inexpensive single-value replacement.
|
|
208
266
|
|
|
209
267
|
Reactive views require `turbo-rails` and a working Action Cable adapter in the
|
|
210
|
-
host application.
|
|
211
|
-
|
|
268
|
+
host application. The Solid Objects engine must be mounted so its signed
|
|
269
|
+
component endpoint is reachable. Reactive views are optional; the actor
|
|
270
|
+
runtime itself does not depend on Turbo.
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# config/routes.rb
|
|
274
|
+
mount SolidObjects::Engine => "/solid_objects"
|
|
275
|
+
```
|
|
212
276
|
|
|
213
277
|
## Installation
|
|
214
278
|
|
|
@@ -274,6 +338,50 @@ can generate the gem RBI with:
|
|
|
274
338
|
bundle exec tapioca gem solid_objects
|
|
275
339
|
```
|
|
276
340
|
|
|
341
|
+
## Upgrading
|
|
342
|
+
|
|
343
|
+
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
|
|
344
|
+
notes, then update the gem:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
bundle update solid_objects
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
If the `Gemfile` pins an exact version, update that constraint first and run
|
|
351
|
+
`bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
|
|
352
|
+
migrations.
|
|
353
|
+
|
|
354
|
+
Copy only migrations that the newer gem has added, migrate, and verify the
|
|
355
|
+
installation:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
bin/rails solid_objects:install:migrations
|
|
359
|
+
bin/rails db:migrate
|
|
360
|
+
bin/rails solid_objects:doctor
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The migration task skips engine migrations already present in the application
|
|
364
|
+
and gives new migrations host-specific timestamps. Inspect the resulting
|
|
365
|
+
`db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
|
|
366
|
+
`generate solid_objects:install` during an upgrade because that also attempts
|
|
367
|
+
to regenerate the application initializer.
|
|
368
|
+
|
|
369
|
+
When Solid Objects uses a separate database configuration named `actors`, copy
|
|
370
|
+
and run migrations through that database's configured migration path:
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
DATABASE=actors bin/rails solid_objects:install:migrations
|
|
374
|
+
bin/rails db:migrate:actors
|
|
375
|
+
bin/rails solid_objects:doctor
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
For production, back up the actor database and run new migrations before
|
|
379
|
+
starting application or Solid Objects worker processes that require the new
|
|
380
|
+
schema. Restart the web and Solid Objects worker fleet after the bundle and
|
|
381
|
+
schema are current. For releases that change actor state versions, also follow
|
|
382
|
+
the [state migration and rolling-deployment guide](docs/state-migrations.md);
|
|
383
|
+
Rails schema migrations and actor state migrations are separate concerns.
|
|
384
|
+
|
|
277
385
|
## Worker requirements
|
|
278
386
|
|
|
279
387
|
Synchronous actors can be adopted without adding a long-running process. Start
|
|
@@ -290,7 +398,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
|
|
|
290
398
|
| `emit` without an actor callback | Effect worker |
|
|
291
399
|
| `emit` with success or failure callback | Effect worker and actor worker |
|
|
292
400
|
| Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
|
|
293
|
-
|
|
|
401
|
+
| Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
|
|
294
402
|
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
|
|
295
403
|
|
|
296
404
|
One command starts every Solid Objects role:
|
|
@@ -929,7 +1037,7 @@ See the [development guide](docs/development.md) and
|
|
|
929
1037
|
|
|
930
1038
|
## Status
|
|
931
1039
|
|
|
932
|
-
Implemented and tested in 0.
|
|
1040
|
+
Implemented and tested in 0.4:
|
|
933
1041
|
|
|
934
1042
|
- Rails engine, install generator, migrations, and `solid_objects` executable;
|
|
935
1043
|
- actor registry, references, JSON state, and state migrations;
|
|
@@ -947,7 +1055,8 @@ Implemented and tested in 0.3:
|
|
|
947
1055
|
- one-shot and recurring per-actor reminders;
|
|
948
1056
|
- authorized actor destruction with fenced stale-write rejection and cascading
|
|
949
1057
|
durable-work cleanup;
|
|
950
|
-
- durable observable
|
|
1058
|
+
- durable observable invalidations, scalar Turbo replacement, and authorized
|
|
1059
|
+
request-time ERB component refresh;
|
|
951
1060
|
- process registration, heartbeats, caller shutdown, cleanup, and bounded
|
|
952
1061
|
message/process retention plus opt-in actor-instance expiration;
|
|
953
1062
|
- an opt-in Minitest helper for actor-state isolation and deterministic async
|
|
@@ -961,8 +1070,8 @@ Partially implemented:
|
|
|
961
1070
|
run periodic maintenance automatically;
|
|
962
1071
|
- cross-process wake-up uses polling; PostgreSQL notifications and optional
|
|
963
1072
|
Redis acceleration are not implemented;
|
|
964
|
-
- live observable replacement
|
|
965
|
-
|
|
1073
|
+
- live observable and component replacement work, while Turbo append actions
|
|
1074
|
+
remain future work;
|
|
966
1075
|
- local admission limits exist, but distributed rate limits and global
|
|
967
1076
|
admission control do not; and
|
|
968
1077
|
- administration views and pruning commands exist, but scheduled maintenance
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "action_controller/base"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class ComponentsController < ActionController::Base
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
# @rbs () -> void
|
|
10
|
+
def show
|
|
11
|
+
registration = ComponentRegistration.from_token(
|
|
12
|
+
params.require(:token)
|
|
13
|
+
)
|
|
14
|
+
requested_revision = requested_revision_key
|
|
15
|
+
snapshot = ActorSnapshot.new(registration.reference)
|
|
16
|
+
return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
|
|
17
|
+
|
|
18
|
+
authorization_context = SolidObjects
|
|
19
|
+
.configuration
|
|
20
|
+
.component_authorization_context
|
|
21
|
+
.call(controller: self)
|
|
22
|
+
rendered = ComponentRenderer.new(
|
|
23
|
+
snapshot:,
|
|
24
|
+
component_name: registration.component_name,
|
|
25
|
+
dependencies: registration.dependencies,
|
|
26
|
+
view_context: component_view_context,
|
|
27
|
+
authorization_context:
|
|
28
|
+
).call
|
|
29
|
+
response.headers["Cache-Control"] = "private, no-store"
|
|
30
|
+
render html: component_frame(registration, snapshot, rendered)
|
|
31
|
+
rescue Unauthorized
|
|
32
|
+
head :forbidden
|
|
33
|
+
rescue UnknownComponent
|
|
34
|
+
head :not_found
|
|
35
|
+
rescue ActionController::ParameterMissing,
|
|
36
|
+
ArgumentError,
|
|
37
|
+
InvalidComponentToken
|
|
38
|
+
head :bad_request
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Array[Integer]
|
|
44
|
+
def requested_revision_key
|
|
45
|
+
instance_id = Integer(params.fetch(:instance_id), 10)
|
|
46
|
+
revision = Integer(params.fetch(:revision), 10)
|
|
47
|
+
raise ArgumentError if instance_id.negative? || revision.negative?
|
|
48
|
+
|
|
49
|
+
[ instance_id, revision ]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @rbs (Array[Integer], ActorSnapshot) -> bool
|
|
53
|
+
def newer_than_snapshot?(requested_revision, snapshot)
|
|
54
|
+
(requested_revision <=> [ snapshot.instance_id, snapshot.revision ]) == 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs () -> untyped
|
|
58
|
+
def component_view_context
|
|
59
|
+
if defined?(Rails) && Rails.application
|
|
60
|
+
prepend_view_path(*Rails.application.paths["app/views"].existent)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
view_context.tap do |context|
|
|
64
|
+
context.extend(Rails.application.helpers) if defined?(Rails) && Rails.application
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
|
|
69
|
+
def component_frame(registration, snapshot, rendered)
|
|
70
|
+
target = DomIdentity.component(
|
|
71
|
+
registration.reference,
|
|
72
|
+
registration.component_name
|
|
73
|
+
)
|
|
74
|
+
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
|
|
75
|
+
%(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -9,11 +9,20 @@ module SolidObjects
|
|
|
9
9
|
view_context: self,
|
|
10
10
|
authorization_context:
|
|
11
11
|
)
|
|
12
|
-
subscription = tag.turbo_cable_stream_source(
|
|
13
|
-
channel: "SolidObjects::ActorChannel",
|
|
14
|
-
token: StreamToken.generate(reference)
|
|
15
|
-
)
|
|
16
12
|
content = capture(actor, &block)
|
|
13
|
+
subscription_attributes = {
|
|
14
|
+
channel: "SolidObjects::ActorChannel",
|
|
15
|
+
token: StreamToken.generate(
|
|
16
|
+
reference,
|
|
17
|
+
observables: actor.scalar_observable_names
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
if actor.component_tokens.any?
|
|
21
|
+
subscription_attributes[:data] = {
|
|
22
|
+
components: JSON.generate(actor.component_tokens)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
subscription = tag.turbo_cable_stream_source(**subscription_attributes)
|
|
17
26
|
|
|
18
27
|
content_tag(
|
|
19
28
|
:div,
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def change
|
|
6
|
+
add_column SolidObjects.table_name(:instances),
|
|
7
|
+
:state_revision,
|
|
8
|
+
:bigint,
|
|
9
|
+
null: false,
|
|
10
|
+
default: 0
|
|
11
|
+
end
|
|
12
|
+
end
|
data/docs/architecture.md
CHANGED
|
@@ -120,7 +120,11 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
|
|
|
120
120
|
|
|
121
121
|
### Broadcast worker
|
|
122
122
|
|
|
123
|
-
The broadcast worker claims committed observable-change rows, renders
|
|
123
|
+
The broadcast worker claims committed observable-change rows, renders
|
|
124
|
+
idempotent scalar Turbo replacements with component invalidation metadata,
|
|
125
|
+
broadcasts to a signed actor stream, and records delivery. It never renders
|
|
126
|
+
personalized component HTML. Current actor state remains the reconnect and
|
|
127
|
+
request-time component source of truth.
|
|
124
128
|
|
|
125
129
|
### Process registry
|
|
126
130
|
|
|
@@ -288,13 +292,14 @@ Successful completion uses one database transaction:
|
|
|
288
292
|
3. Lock the durable message and verify its claimed membership belongs to that owner, activation token, and generation.
|
|
289
293
|
4. Execute registered same-database commit actions.
|
|
290
294
|
5. Update native JSON state and state version.
|
|
291
|
-
6.
|
|
292
|
-
7.
|
|
293
|
-
8. Insert
|
|
294
|
-
9. Insert
|
|
295
|
-
10. Insert
|
|
296
|
-
11.
|
|
297
|
-
12.
|
|
295
|
+
6. Advance the actor state revision to the completed message sequence.
|
|
296
|
+
7. Store the completion timestamp and result on the durable message and delete claimed membership.
|
|
297
|
+
8. Insert staged effects.
|
|
298
|
+
9. Insert or update staged reminders.
|
|
299
|
+
10. Insert staged actor-message outbox rows.
|
|
300
|
+
11. Insert changed-observable broadcast rows.
|
|
301
|
+
12. Update actor last-used time.
|
|
302
|
+
13. Commit.
|
|
298
303
|
|
|
299
304
|
Any lease or message predicate failure raises `LostActivation` and rolls back every item. The stale worker discards its in-memory activation.
|
|
300
305
|
|
|
@@ -460,25 +465,61 @@ Large repairs use `async(..., available_at:)` to spread work over an application
|
|
|
460
465
|
|
|
461
466
|
## Realtime integration
|
|
462
467
|
|
|
463
|
-
`solid_object` performs an authorized state read for initial rendering and
|
|
468
|
+
`solid_object` performs an authorized state read for initial rendering and
|
|
469
|
+
emits:
|
|
464
470
|
|
|
465
471
|
- A stable scope DOM ID derived from actor type and a SHA-256 digest of actor ID
|
|
466
472
|
- One Turbo Cable subscription element for the actor
|
|
467
473
|
- Stable child target IDs for values and components
|
|
468
474
|
- A signed actor token used by the channel subscription
|
|
475
|
+
- Signed component registrations containing a conventional component name,
|
|
476
|
+
explicit observable dependencies, the initial actor incarnation/revision,
|
|
477
|
+
and a same-origin engine refresh path
|
|
469
478
|
|
|
470
479
|
```erb
|
|
471
480
|
<%= solid_object current_cart do |cart| %>
|
|
472
481
|
Cart items: <%= cart.items_count %>
|
|
473
|
-
<%= cart.component :summary %>
|
|
482
|
+
<%= cart.component :summary, observes: %i[items checkout_status] %>
|
|
474
483
|
<% end %>
|
|
475
484
|
```
|
|
476
485
|
|
|
477
486
|
The signed token proves integrity, not authorization. `ActorChannel#subscribed` verifies the token, resolves the registered actor type, invokes `authorize_subscription`, and only then streams.
|
|
478
487
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
488
|
+
Scalar observable calls remain direct escaped Turbo replacements. A reactive
|
|
489
|
+
component resolves only `actors/<actor_class>/_<component>`, receives its
|
|
490
|
+
declared observables as frozen Ruby values, and cannot read raw state or a
|
|
491
|
+
dependency it did not declare. A static initial-only component can still use a
|
|
492
|
+
server-selected explicit partial; a reactive component cannot.
|
|
493
|
+
|
|
494
|
+
Broadcast replacements happen after the actor transaction commits because only
|
|
495
|
+
a committed broadcast outbox row can be delivered. Multiple scalar values and
|
|
496
|
+
components share the same Action Cable connection and one actor subscription.
|
|
497
|
+
One outbox row still exists per changed observable.
|
|
498
|
+
|
|
499
|
+
The shared stream contains invalidation metadata and scalar HTML only for the
|
|
500
|
+
scalar targets signed into that scope's stream token. Component-only
|
|
501
|
+
dependencies do not send their values to the browser, and the stream never
|
|
502
|
+
contains personalized component HTML. For each subscription, `ActorChannel`
|
|
503
|
+
matches the changed observable to registered component dependencies. It
|
|
504
|
+
coalesces multiple dependencies at the same message sequence and drops older
|
|
505
|
+
revision pairs. A component invalidation replaces its stable target with a
|
|
506
|
+
Turbo Frame whose source is the signed engine endpoint.
|
|
507
|
+
|
|
508
|
+
The browser then makes an ordinary cookie-bearing HTTP request. The engine
|
|
509
|
+
controller derives a request-specific context through
|
|
510
|
+
`component_authorization_context`, calls `authorize_query` for the component
|
|
511
|
+
name and every declared dependency, renders the host partial from a new
|
|
512
|
+
committed snapshot, and returns `private, no-store` HTML. Subscribers to the
|
|
513
|
+
same actor can therefore receive different HTML without sharing it through
|
|
514
|
+
Cable or the database.
|
|
515
|
+
|
|
516
|
+
Each channel subscription transmits current scalar replacements and compares
|
|
517
|
+
each component's signed initial revision against the latest committed
|
|
518
|
+
`(instance_id, state_revision)` pair, including after reconnect. Missing a
|
|
519
|
+
broadcast therefore creates temporary staleness, not permanent divergence.
|
|
520
|
+
The instance primary key distinguishes destroy-and-recreate incarnations.
|
|
521
|
+
Replacing the full frame on each newer invalidation detaches an older in-flight
|
|
522
|
+
frame, preventing its slower response from replacing the current generation.
|
|
482
523
|
|
|
483
524
|
## Authorization
|
|
484
525
|
|
|
@@ -497,6 +538,11 @@ recognizable.
|
|
|
497
538
|
|
|
498
539
|
No controller, channel, or administrative command treats an actor ID, message ID, request ID, or signed stream name as authorization.
|
|
499
540
|
|
|
541
|
+
Initial component rendering, Cable subscription, and request-time component
|
|
542
|
+
refresh deliberately use different authorization contexts. Signed component
|
|
543
|
+
tokens constrain actor identity, component convention, dependencies, revision,
|
|
544
|
+
and same-origin refresh path but never grant access.
|
|
545
|
+
|
|
500
546
|
Actor IDs are bounded UTF-8 strings and never become constant names, SQL identifiers, file paths, or raw stream names.
|
|
501
547
|
|
|
502
548
|
## Serialization
|
|
@@ -639,7 +685,7 @@ PostgreSQL transaction-level advisory locks may be used for optional singleton m
|
|
|
639
685
|
| Create actor and allocate message sequence | Instance insert/lock, sequence increment, durable message and ready-membership inserts |
|
|
640
686
|
| Claim activation | Backend claim transaction, generation increment, owner and expiry |
|
|
641
687
|
| Claim next message | Move ready membership to claimed membership conditioned on lease |
|
|
642
|
-
| Successful message commit | Fenced state, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
|
|
688
|
+
| Successful message commit | Fenced state and monotonic revision, durable message result, claimed-membership deletion, effects, reminders, actor outbox, broadcasts |
|
|
643
689
|
| Failed message attempt | Conditional error, claimed deletion, ready reinsertion or dead letter |
|
|
644
690
|
| Renew or release lease | Conditional instance update |
|
|
645
691
|
| Destroy actor | Instance identity lock and cascading delete of state, mailbox, reminders, and outboxes |
|
|
@@ -687,7 +733,7 @@ All backends use unique identity and sequence constraints, short transactions, a
|
|
|
687
733
|
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
|
|
688
734
|
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
|
|
689
735
|
21. **How are subscriptions authorized?** Verify signed identity, resolve registered type, invoke host authorization, then stream.
|
|
690
|
-
22. **How are lost broadcasts recovered?** Current-state refresh after reconnect; durable outbox retries server delivery.
|
|
736
|
+
22. **How are lost broadcasts recovered?** Current-state scalar replacement and authorized component refresh after reconnect; durable outbox retries server delivery.
|
|
691
737
|
23. **How are actor-to-actor cycles handled?** Synchronous actor waits are rejected; asynchronous request/result messages avoid call-stack cycles.
|
|
692
738
|
24. **Which operations are transactional?** The transaction map above lists every atomic boundary. Actor code and external I/O are outside; registered same-pool commit actions execute inside the fenced state/message transaction.
|
|
693
739
|
25. **Which guarantees depend on PostgreSQL?** None of the public semantics are PostgreSQL-only. PostgreSQL and MySQL depend on row-lock claiming; SQLite depends on serialized write transactions. Each backend's guarantee depends on its adapter-specific integration tests.
|
data/docs/authorization.md
CHANGED
|
@@ -10,7 +10,7 @@ intentionally inert until the host application defines its trust boundary.
|
|
|
10
10
|
| Policy | Gates | Caller context | Risk if opened globally |
|
|
11
11
|
| --- | --- | --- | --- |
|
|
12
12
|
| `authorize_message` | Direct actor methods, explicit `sync` messages, and public `async` enqueue | Value passed as `authorization_context:`; often a user, service principal, or trusted internal marker | Anyone reaching the call site can mutate any known actor identity |
|
|
13
|
-
| `authorize_query` | Attribute reads, declared queries, committed snapshots, observable reads, and component
|
|
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
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 |
|
|
@@ -20,6 +20,43 @@ invocation as a message or query. Internal reminder, effect-callback, and
|
|
|
20
20
|
actor-to-actor deliveries come from
|
|
21
21
|
already committed runtime rows and do not re-enter the public client policy.
|
|
22
22
|
|
|
23
|
+
## Realtime authorization contexts
|
|
24
|
+
|
|
25
|
+
Reactive components cross three Rails execution contexts and authorize at all
|
|
26
|
+
three boundaries:
|
|
27
|
+
|
|
28
|
+
1. `solid_object(..., authorization_context:)` uses the explicit Action View
|
|
29
|
+
render context for initial scalar and component reads.
|
|
30
|
+
2. `ActorChannel` passes its authenticated `ActionCable::Connection` to
|
|
31
|
+
`authorize_subscription`.
|
|
32
|
+
3. `ComponentsController` resolves a fresh context for the cookie-bearing HTTP
|
|
33
|
+
request and calls `authorize_query` for the component name and every
|
|
34
|
+
dependency.
|
|
35
|
+
|
|
36
|
+
Configure the refresh resolver when the query policy expects a user or service
|
|
37
|
+
principal rather than the engine controller:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
SolidObjects.configure do |configuration|
|
|
41
|
+
configuration.component_authorization_context = lambda do |controller:|
|
|
42
|
+
Current.user
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Authentication middleware must populate `Current.user` for the refresh
|
|
48
|
+
request. Do not copy an Action View object or Cable connection into the signed
|
|
49
|
+
token. Those objects are request-specific and the token provides integrity,
|
|
50
|
+
not authorization.
|
|
51
|
+
|
|
52
|
+
Component partials receive the resolved value as the
|
|
53
|
+
`authorization_context` local, allowing two authorized viewers to render
|
|
54
|
+
different projections. Responses use `Cache-Control: private, no-store`.
|
|
55
|
+
Durable outbox rows and shared Cable messages never contain component HTML.
|
|
56
|
+
The stream token also signs the scalar observable targets rendered into that
|
|
57
|
+
specific scope. Component-only dependencies send invalidation metadata but not
|
|
58
|
+
their state value to the browser.
|
|
59
|
+
|
|
23
60
|
## A tenant-aware policy
|
|
24
61
|
|
|
25
62
|
Pass the authenticated user as the call context:
|
data/docs/correctness.md
CHANGED
|
@@ -101,8 +101,8 @@ The following are atomic:
|
|
|
101
101
|
- activation owner, expiration, and generation acquisition;
|
|
102
102
|
- ready-to-claimed membership move and attempt increment;
|
|
103
103
|
- state, state version, message result/completion, claimed deletion, effects,
|
|
104
|
-
same-database commit actions, reminders, outbound actor messages,
|
|
105
|
-
|
|
104
|
+
same-database commit actions, reminders, outbound actor messages, observable
|
|
105
|
+
broadcasts, and the monotonic actor state revision;
|
|
106
106
|
- failed-attempt record plus ready reinsertion or dead letter;
|
|
107
107
|
- effect completion plus its optional actor outcome message;
|
|
108
108
|
- reminder occurrence enqueue plus reminder advancement; and
|
|
@@ -116,6 +116,32 @@ it is available only when Solid Objects and `ActiveRecord::Base` share one
|
|
|
116
116
|
connection pool. Commit actions must contain only bounded database work.
|
|
117
117
|
External I/O belongs in the effect outbox.
|
|
118
118
|
|
|
119
|
+
## Reactive components
|
|
120
|
+
|
|
121
|
+
A successful fenced turn advances `instances.state_revision` to that message's
|
|
122
|
+
per-actor sequence and inserts changed-observable broadcast rows in the same
|
|
123
|
+
transaction. A rollback, retryable handler failure, or lost activation advances
|
|
124
|
+
neither. Component HTML is not durable and is never placed on the shared actor
|
|
125
|
+
stream.
|
|
126
|
+
|
|
127
|
+
Each component token signs the actor identity, conventional component name,
|
|
128
|
+
explicit dependencies, initial instance ID and revision, and same-origin
|
|
129
|
+
refresh path. The signature detects modification but grants no access. Initial
|
|
130
|
+
rendering invokes query authorization, Cable separately invokes subscription
|
|
131
|
+
authorization, and the cookie-bearing refresh request invokes query
|
|
132
|
+
authorization again for the component name and every dependency.
|
|
133
|
+
|
|
134
|
+
The actor stream token separately signs the scalar observable targets rendered
|
|
135
|
+
into its scope. A component dependency that has no scalar target carries only
|
|
136
|
+
its name and revision over Cable, not its serialized value.
|
|
137
|
+
|
|
138
|
+
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
|
|
139
|
+
changed by the same turn, and ignores an older pair after a newer one. A new
|
|
140
|
+
invalidation replaces the whole Turbo Frame generation. A response owned by
|
|
141
|
+
the detached older frame cannot overwrite the current frame. Reconnect
|
|
142
|
+
compares the component's signed initial pair with the current instance row and
|
|
143
|
+
requests the latest committed snapshot when stale.
|
|
144
|
+
|
|
119
145
|
## Synchronous invocation
|
|
120
146
|
|
|
121
147
|
A direct reference method or explicit `sync` call durably enqueues an ordinary
|
data/docs/database-schema.md
CHANGED
|
@@ -8,9 +8,15 @@ migrations load. No partial indexes are used.
|
|
|
8
8
|
### `instances`
|
|
9
9
|
|
|
10
10
|
One row per `(actor_type, actor_id)`. Stores JSON state, state version,
|
|
11
|
-
next-message sequence, activation
|
|
12
|
-
state, and lifecycle timestamps. The
|
|
13
|
-
process row cannot make two
|
|
11
|
+
monotonic state revision, next-message sequence, activation
|
|
12
|
+
owner/token/expiration/generation, pause state, and lifecycle timestamps. The
|
|
13
|
+
owner/token pairing is constrained so one process row cannot make two
|
|
14
|
+
concurrent activations appear identical.
|
|
15
|
+
|
|
16
|
+
`state_revision` advances to the successful message sequence in the same
|
|
17
|
+
fenced transaction as state and outboxes. Reactive components compare
|
|
18
|
+
`(instance_id, state_revision)` so pruned message history cannot make revisions
|
|
19
|
+
regress and destroy-and-recreate produces a new incarnation.
|
|
14
20
|
|
|
15
21
|
Deleting an instance is the actor-incarnation boundary. Foreign keys cascade
|
|
16
22
|
the delete through messages, ready and claimed memberships, reminders, effects,
|
|
@@ -85,8 +91,10 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
|
|
|
85
91
|
### `broadcasts`
|
|
86
92
|
|
|
87
93
|
Durable observable-change outbox. The unique message/observable key prevents
|
|
88
|
-
duplicate rows for one actor turn.
|
|
89
|
-
|
|
94
|
+
duplicate rows for one actor turn. Rows contain the observable JSON value and
|
|
95
|
+
message/instance references used to derive invalidation metadata, never
|
|
96
|
+
personalized rendered HTML. Claim and delivery indexes support retries and
|
|
97
|
+
cleanup.
|
|
90
98
|
|
|
91
99
|
### `dead_letters`
|
|
92
100
|
|