solid_objects 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/app/controllers/solid_objects/components_controller.rb +3 -18
  4. data/benchmark/support.rb +47 -5
  5. data/docs/benchmarks.md +19 -2
  6. data/docs/local-testing.md +91 -0
  7. data/docs/realtime.md +74 -6
  8. data/docs/roadmap.md +56 -36
  9. data/exe/solid_objects +12 -1
  10. data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
  11. data/lib/solid_objects/actor_channel.rb +50 -10
  12. data/lib/solid_objects/callable_keywords.rb +29 -0
  13. data/lib/solid_objects/component_subscriptions.rb +19 -11
  14. data/lib/solid_objects/configuration.rb +13 -0
  15. data/lib/solid_objects/database_adapter.rb +37 -0
  16. data/lib/solid_objects/database_adapters/mysql.rb +28 -0
  17. data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
  18. data/lib/solid_objects/database_adapters/sqlite.rb +5 -0
  19. data/lib/solid_objects/doctor.rb +16 -0
  20. data/lib/solid_objects/payload_broadcast.rb +29 -1
  21. data/lib/solid_objects/supervisor.rb +76 -0
  22. data/lib/solid_objects/version.rb +1 -1
  23. data/lib/solid_objects/wake_up_adapters/redis.rb +183 -0
  24. data/lib/solid_objects.rb +2 -0
  25. data/sig/generated/controllers/solid_objects/components_controller.rbs +0 -8
  26. data/sig/generated/lib/solid_objects/actor_channel.rbs +19 -0
  27. data/sig/generated/lib/solid_objects/callable_keywords.rbs +16 -0
  28. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +5 -0
  29. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  30. data/sig/generated/lib/solid_objects/database_adapter.rbs +17 -0
  31. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
  32. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  33. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  34. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  35. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
  36. data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
  37. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
  38. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17e88ea06322a5e262d146e3d96a0b0d54d2339d46c6681a2fdb0f8d244a3640
4
- data.tar.gz: d33b76b4e741921b4e199f3f91af652c06217960606dc7e3e95a03b28d34d3db
3
+ metadata.gz: 935a2d2bb12fbc5b39121fe44004d0cc0671c3bef82012cf9b024089c3546ac9
4
+ data.tar.gz: 15de97ea8547bfb552ae848b2ae523fdd6c48a557313ba3fd87d931ee770f9b1
5
5
  SHA512:
6
- metadata.gz: 0a217bfdb8454acbf7caa7a153095455e8b421aab4ebc7a16be056efa49db78bc4e0c452f50e24c04f1aad5177aafdb16ebff67a974618a0134c302581edf85a
7
- data.tar.gz: 284a15eb44417ebaad3ec0ccad3b180999ed94df9d7ca73b74fcc70ed136f3458613ecbadb99115f13e93310d684a0451a62f6887dd7b0c2bdcf1af14ade2fcf
6
+ metadata.gz: 6633846db212684a9e6d687f76e559cd51448171bd9e067534024e9a067534f1ff7eab223e3c1ab80b7a02765b909a324f2dbb9b1f527e5846d933de5b3639c7
7
+ data.tar.gz: ce930198b48379439c4947dd41ea5d3a0577ad8ce49eb1513715075ccf23c03b23237bcee77f06a898ea5460b83e3bf6b0c8a54f7b9c16e03c159c2572a03306
data/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-08-10
4
+
5
+ - Report a denied CLI command as a policy decision rather than a crash.
6
+ Administration denies by default, so an unconfigured host met a thirty-line
7
+ Ruby backtrace on its first `solid_objects` command. The executable now
8
+ prints the refusal and the setting that grants access, and exits 1.
9
+ - Measure the query count for a synchronous call. `benchmark/query_count.rb`
10
+ only measured a worker turn, so the documented synchronous number had no
11
+ script behind it. It reports three now: a message turn costs 26 queries
12
+ rather than the documented 29, the caller of a synchronous call costs 49, and
13
+ a synchronous call in total costs 75, being a caller plus the turn it waits
14
+ on. Counting is scoped to the measuring thread, since a worker loop polls
15
+ whether or not a call is in flight and a process-wide count folds those polls
16
+ into the result.
17
+
18
+ - Run payload broadcast blocks against the actor instance, like every other
19
+ block in the actor DSL. `self` was the actor class, so an actor instance
20
+ method called from a payload block raised
21
+ `NoMethodError: undefined method 'x' for class PlaymatRoom`. Blocks keep
22
+ receiving the actor and the authorization context as arguments, so the
23
+ documented signature is unaffected. A block that relied on the class receiver
24
+ now raises `InvalidPayloadBroadcast` naming the method and the change instead
25
+ of an unexplained `NameError`.
26
+ - Add `payload_authorization_context`, the payload counterpart to
27
+ `component_authorization_context`. Payloads are computed inside the channel,
28
+ so without a resolver the payload block and its `authorize_query` call
29
+ received the raw Action Cable connection while a controller render passed an
30
+ application object, and the authorization hook had to tell them apart. The
31
+ resolver may also accept `payload_name:`. It defaults to returning the
32
+ connection unchanged.
33
+ - Confine a failing payload to itself. A raising payload block propagated out of
34
+ the channel: on subscribe it rejected the subscription, and on a broadcast it
35
+ abandoned the remaining payload names, which showed up in the browser only as
36
+ reactive updates that stopped arriving. A failure is now reported as
37
+ `solid_objects.payload_broadcast_failed` with the actor type, actor id,
38
+ payload name, and exception class, and delivery continues. The exception
39
+ message is deliberately excluded so subscriber state cannot leak into logs. A
40
+ revision with a failed payload does not advance the delivery watermark, so a
41
+ transient failure is retried on the next broadcast instead of being recorded
42
+ as delivered and deduplicated away.
43
+ - Run retention on the supervisor rather than leaving it configured but
44
+ unscheduled. Every actor call writes a durable message row, so a policy that
45
+ nothing invokes let history grow without bound until an application scheduled
46
+ its own job. `retention_interval` defaults to one hour, and zero disables it.
47
+ Retention runs on its own thread, so a slow pass cannot delay replacing a
48
+ crashed role, and a failed pass retries at monitor cadence with a doubling
49
+ backoff rather than deferring for the whole interval.
50
+ - Batch component refreshes on reconnect. A reconnecting subscription refreshed
51
+ every stale component individually, ignoring the batches those components
52
+ declared, so a page with twenty batched components issued twenty requests
53
+ instead of one. That happens at the worst moment: a server restart reconnects
54
+ every client at once. Reconnect now shares the batching the live invalidation
55
+ path uses.
56
+ - Cover the reconnect burst in the browser suite: convergence of batched and
57
+ unbatched components, an inert replay of an already-applied revision,
58
+ cancellation of the request left in flight by the drop, incarnation ordering
59
+ after a destroy and recreate, and payload delivery exactly once per revision.
60
+ - Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
61
+ 4.0 against Rails 8.0 and 8.1.
62
+
63
+ ## 0.9.0 - 2026-08-10
64
+
65
+ - Add a browser test suite running the refresh modules against real Chromium and
66
+ a real Turbo build, covering `component_refresh.js`, which previously had no
67
+ tests at all. Every batching defect that reached production passed the jsdom
68
+ suite, because jsdom cannot model Turbo applying a morph, task boundaries
69
+ between socket deliveries, or abort semantics.
70
+ - Verify the database server. Each adapter reports its version against the
71
+ oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
72
+ SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
73
+ InnoDB, since a non-transactional engine would silently break fenced commits.
74
+ The doctor reports this as `database_server` and warns rather than failing:
75
+ refusing to run on an untested server would be a worse failure than running
76
+ on one.
77
+ - Add `SolidObjects::WakeUpAdapters::Redis`, an optional cross-process wake-up
78
+ using Redis publish and subscribe. This is the option for MySQL, which has no
79
+ notification primitive. Measured cross-process wake-up latency drops from
80
+ 103.8 ms to 5.7 ms at p50. One background subscription per process fans out to
81
+ every waiting role in memory. The `redis` gem is not a dependency of this gem,
82
+ and `WakeUpAdapters.for` does not select it, so adopting Redis stays explicit.
83
+
3
84
  ## 0.8.0 - 2026-08-10
4
85
 
5
86
  - Replace a supervised role whose thread died. A role that raised left its
@@ -138,26 +138,11 @@ module SolidObjects
138
138
  # @rbs (Array[ComponentRegistration]) -> untyped
139
139
  def component_authorization_context(registrations)
140
140
  callable = SolidObjects.configuration.component_authorization_context
141
- return callable.call(controller: self) unless accepts_registrations?(callable)
142
-
143
- callable.call(controller: self, registrations:)
144
- end
145
-
146
- # A lambda answers `parameters` directly; a callable object answers it
147
- # through its `call` method.
148
- # @rbs (untyped) -> bool
149
- def accepts_registrations?(callable)
150
- callable_parameters(callable).any? do |type, name|
151
- type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
141
+ unless CallableKeywords.accepts?(callable, :registrations)
142
+ return callable.call(controller: self)
152
143
  end
153
- end
154
-
155
- # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
156
- def callable_parameters(callable)
157
- return callable.parameters if callable.respond_to?(:parameters)
158
- return callable.method(:call).parameters if callable.respond_to?(:call)
159
144
 
160
- []
145
+ callable.call(controller: self, registrations:)
161
146
  end
162
147
 
163
148
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
data/benchmark/support.rb CHANGED
@@ -291,24 +291,66 @@ module SolidObjectsBenchmark
291
291
 
292
292
  # @rbs () -> void
293
293
  def query_count
294
+ turn = message_turn_query_count
295
+ caller_queries = synchronous_caller_query_count
296
+ puts "database queries: #{turn} for 1 message turn"
297
+ puts "database queries: #{caller_queries} for the caller of 1 synchronous call"
298
+ puts "database queries: #{caller_queries + turn} for 1 synchronous call, caller plus the turn it waits on"
299
+ end
300
+
301
+ private
302
+
303
+ # Runs the turn on the measuring thread, so nothing else can contribute.
304
+ # @rbs () -> Integer
305
+ def message_turn_query_count
294
306
  CounterActor.ref("queries").async(:increment)
295
307
  worker = SolidObjects::Worker.new
308
+ count_queries { worker.run_once }
309
+ ensure
310
+ worker&.stop
311
+ end
312
+
313
+ # A synchronous call also registers or heartbeats the caller process,
314
+ # claims the activation, and observes the result, so the caller costs more
315
+ # than the turn it waits on. Only the caller thread is counted; the worker
316
+ # runs on its own thread and its turn is measured separately, because a
317
+ # worker loop also polls and those polls belong to no particular call. The
318
+ # first call is discarded because it pays for activation and caller
319
+ # registration that a steady-state call does not.
320
+ # @rbs () -> Integer
321
+ def synchronous_caller_query_count
322
+ reference = CounterActor.ref("sync-queries")
323
+ worker = SolidObjects::Worker.new
324
+ runner = Thread.new { worker.run }
325
+ reference.sync(:increment)
326
+ count_queries { reference.sync(:increment) }
327
+ ensure
328
+ worker&.request_shutdown
329
+ runner&.join(5)
330
+ worker&.stop
331
+ end
332
+
333
+ # Subscriptions are process-wide and notifications run on the thread that
334
+ # issued the query, so counting is scoped to the measuring thread. Without
335
+ # that, a worker polling in the background inflates the count by however
336
+ # many times it happened to poll during the window.
337
+ # @rbs () { () -> untyped } -> Integer
338
+ def count_queries
339
+ measuring = Thread.current
296
340
  queries = 0
297
341
  subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
342
+ next unless Thread.current.equal?(measuring)
298
343
  next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
299
344
  next if event.payload[:cached]
300
345
 
301
346
  queries += 1
302
347
  end
303
- processed = worker.run_once
304
- puts "database queries: #{queries} for #{processed} message"
348
+ yield
349
+ queries
305
350
  ensure
306
351
  ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
307
- worker&.stop
308
352
  end
309
353
 
310
- private
311
-
312
354
  # @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
313
355
  def render_component(registration, view_context, snapshot: nil)
314
356
  SolidObjects::ComponentRenderer.new(
data/docs/benchmarks.md CHANGED
@@ -40,8 +40,25 @@ scenario used four worker threads.
40
40
  | Process, four workers | 556.5 messages/s |
41
41
  | Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
42
42
  | Activation reuse | 98.0%, four activations for 200 messages |
43
- | Queries for one message turn | 29 |
44
- | Queries for one synchronous call | 49 |
43
+
44
+ Query counts are a property of the code rather than the host, so they are
45
+ tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
46
+ `bundle exec ruby benchmark/query_count.rb`, which is deterministic across runs:
47
+
48
+ | Scenario | Queries |
49
+ | --- | ---: |
50
+ | One message turn | 26 |
51
+ | The caller of one synchronous call | 49 |
52
+ | One synchronous call, caller plus the turn it waits on | 75 |
53
+
54
+ A worker turn fell from the 29 recorded earlier. The caller count is unchanged
55
+ at 49, and the combined figure is new: a synchronous call is a caller and a
56
+ worker turn, and only the sum says what the database actually serves.
57
+
58
+ Counting is scoped to the measuring thread. A worker loop polls whether or not
59
+ a call is in flight, so a process-wide count folds however many polls happened
60
+ to land inside the window into the result. That is also why the caller and the
61
+ turn are measured separately rather than by watching both threads at once.
45
62
 
46
63
  A synchronous call costs far more queries than a worker turn because the caller
47
64
  also registers or heartbeats its caller process, claims the activation, and
@@ -0,0 +1,91 @@
1
+ # Local testing
2
+
3
+ The default suite runs against SQLite and needs nothing extra:
4
+
5
+ ```bash
6
+ bundle exec rake
7
+ ```
8
+
9
+ Everything below is optional. Each adapter and each optional service skips its
10
+ tests when the service is absent, so a missing container degrades coverage
11
+ rather than breaking the run. That is convenient, and it is also a trap: a
12
+ skipped test looks identical to a passing one in the summary line. Check the
13
+ skip count when a change touches an adapter.
14
+
15
+ ## PostgreSQL
16
+
17
+ ```bash
18
+ brew services start postgresql@17
19
+ createuser -h 127.0.0.1 -s solid_objects
20
+ psql -h 127.0.0.1 -d postgres -c "ALTER USER solid_objects WITH PASSWORD 'solid_objects';"
21
+ createdb -h 127.0.0.1 -O solid_objects solid_objects_test
22
+
23
+ SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:5432/solid_objects_test \
24
+ bundle exec rake test
25
+ ```
26
+
27
+ Running this locally is worth the setup: it is what caught the PostgreSQL
28
+ version comparison reading a packed integer, where `170010` compared greater
29
+ than any minimum and made the check useless on the adapter it mattered most for.
30
+
31
+ ## MySQL and Redis in Docker
32
+
33
+ These use non-default ports so they cannot collide with a MySQL or Redis that
34
+ another project is already running:
35
+
36
+ ```bash
37
+ docker run -d --name so-mysql -p 3307:3306 \
38
+ -e MYSQL_ROOT_PASSWORD=solid_objects \
39
+ -e MYSQL_DATABASE=solid_objects_test \
40
+ -e MYSQL_USER=solid_objects \
41
+ -e MYSQL_PASSWORD=solid_objects \
42
+ mysql:8
43
+
44
+ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
45
+ ```
46
+
47
+ ```bash
48
+ SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
49
+ bundle exec rake test
50
+
51
+ SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
52
+ bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
53
+ ```
54
+
55
+ Stop them with `docker rm -f so-mysql so-redis`.
56
+
57
+ ## Recreating a database between runs
58
+
59
+ The test helper migrates unconditionally, so a second run against a database
60
+ that already has the tables fails with a duplicate-table error rather than a
61
+ test failure. Recreate first:
62
+
63
+ ```bash
64
+ dropdb -h 127.0.0.1 solid_objects_test && createdb -h 127.0.0.1 -O solid_objects solid_objects_test
65
+
66
+ docker exec so-mysql mysql -u root -psolid_objects \
67
+ -e "DROP DATABASE IF EXISTS solid_objects_test; CREATE DATABASE solid_objects_test;
68
+ GRANT ALL ON solid_objects_test.* TO 'solid_objects'@'%';"
69
+ ```
70
+
71
+ ## Rails and Ruby span
72
+
73
+ `RAILS_VERSION` pins the Rails line the gemspec advertises:
74
+
75
+ ```bash
76
+ RAILS_VERSION=8.0 bundle install
77
+ RAILS_VERSION=8.0 bundle exec rake test
78
+ ```
79
+
80
+ ## Browser modules
81
+
82
+ ```bash
83
+ npm install
84
+ npm test # jsdom, fast
85
+ npx playwright install chromium
86
+ npm run test:browser # real Chromium and a real Turbo build
87
+ ```
88
+
89
+ The jsdom suite covers logic; the browser suite covers integration with Turbo.
90
+ Both matter: every batching defect that reached production passed the jsdom
91
+ suite alone.
data/docs/realtime.md CHANGED
@@ -128,8 +128,21 @@ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
128
128
  default on SQLite and MySQL, so the same line is safe across adapters. Name
129
129
  `SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.
130
130
 
131
- MySQL has no notification primitive, so MySQL applications keep polling and tune
132
- `polling_interval`.
131
+ MySQL has no notification primitive. MySQL applications either keep polling and
132
+ tune `polling_interval`, or configure the Redis adapter:
133
+
134
+ ```ruby
135
+ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
136
+ url: ENV["REDIS_URL"]
137
+ )
138
+ ```
139
+
140
+ Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
141
+ p50. The `redis` gem is not a dependency of this gem, so applications add it
142
+ themselves. One background subscription per process fans out to every waiting
143
+ role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
144
+ does not select it: Redis is infrastructure this gem otherwise does not require,
145
+ so choosing it is explicit.
133
146
 
134
147
  Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
135
148
  p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
@@ -224,9 +237,11 @@ single actor mutation changes several components, an application pays several
224
237
  round trips for one logical update. A payload broadcast collapses that into one
225
238
  message on the stream the page already has open.
226
239
 
227
- Declare the payload on the actor. The block receives the actor and the
228
- subscriber's authorization context, and it runs **once per subscriber**, so two
229
- sessions watching the same actor never see each other's private state:
240
+ Declare the payload on the actor. The block runs against the actor instance,
241
+ like every other block in the actor DSL, and receives the actor and the
242
+ subscriber's authorization context as arguments. It runs **once per
243
+ subscriber**, so two sessions watching the same actor never see each other's
244
+ private state:
230
245
 
231
246
  ```ruby
232
247
  class PlaymatRoom < SolidObjects::Actor
@@ -246,6 +261,16 @@ class PlaymatRoom < SolidObjects::Actor
246
261
  end
247
262
  ```
248
263
 
264
+ Because the block runs against the actor, an actor instance method is reachable
265
+ without a receiver, so shared logic does not have to be duplicated into the
266
+ block:
267
+
268
+ ```ruby
269
+ broadcast_payload :playmat_state do |_room, authorization|
270
+ { "turn" => turn, "hand" => hand_for(authorization.session_id) }
271
+ end
272
+ ```
273
+
249
274
  Subscribe the scope to it:
250
275
 
251
276
  ```erb
@@ -285,6 +310,26 @@ Payload blocks read committed actor state through the same snapshot components
285
310
  use. They cannot write application records, and the return value must be a JSON
286
311
  object or array so the wire format stays inspectable.
287
312
 
313
+ A payload is one subscriber's view of one name, so a failure is confined to it.
314
+ A raising block does not reject the subscription, stop the other payload names,
315
+ or stop component refreshes on the same connection. The failure is reported as
316
+ `solid_objects.payload_broadcast_failed` carrying the actor type, actor id,
317
+ payload name, and exception class. The exception message is deliberately not
318
+ included: a payload block reads subscriber state, so its message is the one
319
+ place that state could leak into logs.
320
+
321
+ A revision with a failed payload does not advance the delivery watermark, so a
322
+ transient failure is retried on the next broadcast rather than being recorded as
323
+ delivered. Retries are driven by broadcasts rather than a timer, so a payload
324
+ that fails persistently retries once per actor mutation and reports each
325
+ attempt. A repeating stream of `payload_broadcast_failed` for one `payload_name`
326
+ therefore means a persistent fault in that block, not a one-off; a single event
327
+ that does not recur was transient and has already been recovered.
328
+
329
+ A payload the subscriber cannot query is skipped rather than served partially;
330
+ that decision is stable, so it settles the revision and the skip is silent by
331
+ design.
332
+
288
333
  ### mtg-playmat before and after
289
334
 
290
335
  Before, one mutation that touched three observables produced three refresh
@@ -337,13 +382,31 @@ end
337
382
  Callbacks that accept only `controller:` continue to work; the extra keyword is
338
383
  passed only to callables that declare it.
339
384
 
340
- The three contexts are intentionally different:
385
+ Payloads have the same resolver, because they are computed inside the channel
386
+ rather than in a controller. Without one, a payload block and its
387
+ `authorize_query` call receive the Cable connection while a controller render
388
+ passes an application object, and the authorization hook has to tell them
389
+ apart. Resolve both to the same type and it does not:
390
+
391
+ ```ruby
392
+ configuration.component_authorization_context = ->(controller:) { controller.current_account }
393
+ configuration.payload_authorization_context = ->(connection:) { connection.current_account }
394
+ ```
395
+
396
+ The resolved value is what the payload block receives as its second argument and
397
+ what `authorize_query` receives as `authorization_context`. A resolver may also
398
+ accept `payload_name:` when the subject depends on which payload was requested.
399
+ The default returns the connection unchanged, so an application that has not
400
+ configured one is unaffected.
401
+
402
+ The contexts are intentionally different:
341
403
 
342
404
  | Boundary | Authorization context |
343
405
  | --- | --- |
344
406
  | Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
345
407
  | Action Cable subscription | The authenticated Cable connection |
346
408
  | Component refresh | Value returned by `component_authorization_context` for the engine controller request |
409
+ | State payload | Value returned by `payload_authorization_context` for the Cable connection |
347
410
 
348
411
  Do not substitute a signed token for any of them. Keys and locals are visible
349
412
  to the browser and signed for integrity, not encrypted or authorized. Never
@@ -380,6 +443,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
380
443
  the last transmitted pair are ignored. The durable state row remains source of
381
444
  truth.
382
445
 
446
+ Stale components that share a `batch:` are refreshed together, exactly as a
447
+ live invalidation refreshes them, so reconnecting costs one request per batch
448
+ rather than one per component. That matters most on a restart, when every
449
+ client reconnects at once.
450
+
383
451
  The component endpoint rejects a requested revision newer than the committed
384
452
  snapshot. This is a final server-side guard; browser safety primarily comes
385
453
  from monotonic channel filtering plus replace-frame detachment or morph
data/docs/roadmap.md CHANGED
@@ -13,17 +13,27 @@
13
13
  - At-least-once retries, terminal domain rejection, strict poison ordering,
14
14
  dead letters, and tail retry
15
15
  - Transactional effects with success/failure actor messages
16
- - Actor-to-actor asynchronous outbox delivery
16
+ - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
17
+ portable status rows with polling indexes and database check constraints on
18
+ status, which works on all three adapters; a future version may add narrow
19
+ ready/claimed membership tables for very large outboxes, as messages already
20
+ have
17
21
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
18
22
  - Durable observable invalidations, scalar Turbo replacement, keyed ERB
19
23
  components, signed component locals, and authorized replace or morph refresh
20
24
  - Batched component refreshes: components sharing a signed `batch:` collapse to
21
25
  one browser request per revision, served as HTML frames in a JSON envelope
22
26
  - Personalized state payload broadcasts computed per subscriber under that
23
- subscriber's authorization context, fenced by actor revision
27
+ subscriber's authorization context, fenced by actor revision, resolved through
28
+ `payload_authorization_context` so the block and `authorize_query` see the
29
+ same subject a controller render passes, and confined so one failing payload
30
+ cannot reject the subscription or stop its siblings
24
31
  - Reconciliation read APIs
25
32
  - Installation doctor, authorization reference, fit guide, and legacy-state
26
33
  migration cookbook
34
+ - Database server verification: each adapter reports its version against a
35
+ tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the
36
+ doctor warns rather than refusing to run on an untested server
27
37
  - Handler Active Record write isolation, same-database commit actions, ambient
28
38
  transaction rejection, adapter lock/query deadlines, bounded SQLite lock
29
39
  retries outside those deadlines, structured sync timeout diagnostics, and
@@ -32,62 +42,72 @@
32
42
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
33
43
  helper
34
44
  - Supervisor role replacement: a role whose thread dies is restarted until
35
- shutdown is requested, and dead process records are pruned on an interval
45
+ shutdown is requested, and dead process records plus expired message and
46
+ process history are pruned on their own intervals without an application
47
+ scheduling its own job
36
48
  - SQLite, PostgreSQL, and MySQL integration suites
37
49
  - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
38
50
  a listening connection per waiting thread and release on supervisor shutdown
51
+ - Opt-in cross-process wake-up on Redis, the option for MySQL applications,
52
+ measured at 103.8 ms to 5.7 ms at p50; the `redis` gem stays outside this
53
+ gem's dependencies
39
54
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
40
55
  RuboCop policy, and a warning-free Brakeman scan
41
- - Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
42
- and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
43
- rather than assumed
44
- - A JavaScript suite covering the state payload and batched refresh browser
45
- modules, run in CI with Node's test runner and jsdom, with every GitHub
46
- Actions reference pinned to a commit SHA
56
+ - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
57
+ Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
58
+ verified rather than assumed
59
+ - A JavaScript suite covering every browser module, run in CI with Node's test
60
+ runner and jsdom, plus a browser suite running the same modules against real
61
+ Chromium and a real Turbo build, with every GitHub Actions reference pinned to
62
+ a commit SHA. The browser suite covers the reconnect burst: convergence of
63
+ batched and unbatched components, an inert replay of an applied revision,
64
+ cancellation of the request left in flight by the drop, incarnation ordering
65
+ after a destroy and recreate, and payload delivery exactly once per revision
47
66
 
48
67
  ## Partially implemented
49
68
 
50
- - Wake-up strategy: in-process signaling, durable polling, injection, and an
51
- opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
52
- not. In-process signaling cannot cross process boundaries, so without the
53
- adapter a commit in a web process does not wake a broadcast executor in a
54
- worker process and that delivery waits up to `polling_interval`, 100 ms by
55
- default. `WakeUpAdapters.for` removes that delay on PostgreSQL, measured at
56
- 103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
57
- connection per waiting thread outside the pool, and `LISTEN` does not survive
58
- a transaction-pooling proxy such as PgBouncer. MySQL has no notification
59
- primitive, so MySQL applications keep polling.
69
+ - Wake-up strategy: in-process signaling, durable polling, injection, and
70
+ cross-process adapters for PostgreSQL and Redis are implemented and tested.
71
+ What is not done is making any of them automatic. In-process signaling cannot
72
+ cross process boundaries, so by default a commit in a web process does not
73
+ wake a broadcast executor in a worker process and that delivery waits up to
74
+ `polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
75
+ to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
76
+ opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
77
+ thread outside the pool and `LISTEN` does not survive a transaction-pooling
78
+ proxy such as PgBouncer, and Redis is not a dependency of this gem.
79
+ `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
80
+ default elsewhere; it never selects Redis. An application that configures
81
+ nothing keeps polling, and MySQL applications keep polling unless they
82
+ configure Redis explicitly.
60
83
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
61
84
  morphing, personalized refresh authorization, revision fencing, coalescing,
62
85
  reconnect convergence, batched refreshes, and personalized state payloads are
63
86
  implemented; application-directed Turbo append intents are not. Batch
64
87
  coalescing happens in the browser rather than the broadcast executor, so one
65
88
  commit still sends one Action Cable message per changed observable even
66
- though it costs one browser request.
89
+ though it costs one browser request. Reconnect convergence previously
90
+ bypassed batching entirely, issuing one request per stale component at the
91
+ moment a restart reconnects every client at once; it now shares the batching
92
+ the live invalidation path uses. Payload delivery over Action Cable was
93
+ untested end to end, which is how a raising payload block came to reject the
94
+ subscription; it is now covered and confined, and the payload authorization
95
+ context is resolved through `payload_authorization_context` rather than
96
+ handing the block a raw Cable connection.
67
97
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
68
98
  distributed per-actor rate limits and global admission control do not.
69
99
  - Administration: actor and dead-letter views plus policy hooks exist; richer
70
100
  filtering, audit records, and bulk-safe tools do not.
71
- - Browser module coverage: the state payload and batched refresh modules have
72
- JavaScript tests; `component_refresh.js`, which drives individual morph
73
- refreshes, does not.
74
- - Outboxes use portable status rows with polling indexes; future versions may
75
- introduce narrow ready/claimed membership tables for very large outboxes.
76
101
 
77
102
  ## Next milestones
78
103
 
79
- 1. Add an optional Redis wake-up adapter, which is the remaining cross-process
80
- option for MySQL. The PostgreSQL notification adapter, its latency
81
- benchmark, and its concurrency tests are implemented.
82
- 2. Add result lookup by request ID and broader deadlock retry classification.
83
- 3. Add scheduled retention and stale-process maintenance.
84
- 4. Add database/server-version checks and MySQL InnoDB verification at boot.
85
- 5. Add Turbo append intents and expand reconnect coverage in a full browser.
86
- 6. Add distributed rate limits, global admission hooks, and cache-capacity
104
+ 1. Add result lookup by request ID and broader deadlock retry classification.
105
+ 2. Add Turbo append intents.
106
+ 3. Add distributed rate limits, global admission hooks, and cache-capacity
87
107
  eviction.
88
- 7. Expand security scanning and run compatibility CI across supported Rails and
89
- Ruby versions.
90
- 8. Benchmark all workloads under documented hardware/database settings and
108
+ 4. Expand security scanning beyond the Brakeman scan, such as dependency
109
+ auditing and secret scanning.
110
+ 5. Benchmark all workloads under documented hardware/database settings and
91
111
  publish adapter-specific adoption measurements. Throughput, synchronous
92
112
  latency, query counts, and the three reactive delivery paths are measured on
93
113
  SQLite; adapter-specific and end-to-end browser measurements are not.
data/exe/solid_objects CHANGED
@@ -6,4 +6,15 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
6
6
  require "solid_objects"
7
7
  require "solid_objects/cli"
8
8
 
9
- SolidObjects::CLI.start(ARGV)
9
+ begin
10
+ SolidObjects::CLI.start(ARGV)
11
+ rescue SolidObjects::Unauthorized => error
12
+ # Authorization denies by default, so this is what an unconfigured host sees
13
+ # from its first command. A policy decision is not a crash, and printing a
14
+ # backtrace for one buries the single line that says how to grant access.
15
+ warn "solid_objects: #{error.message}"
16
+ warn "Set configuration.authorize_administration in your Solid Objects " \
17
+ "initializer to allow this command. See the commented example in " \
18
+ "config/initializers/solid_objects.rb."
19
+ exit 1
20
+ end
@@ -52,6 +52,14 @@ SolidObjects.configure do |configuration|
52
52
  # Configure component_authorization_context to return the authenticated
53
53
  # principal used for reactive component refreshes.
54
54
 
55
+ # Payloads are delivered over Action Cable, so without a resolver the payload
56
+ # block and authorize_query receive the Cable connection while a controller
57
+ # render passes an application object. Resolve both to the same type and the
58
+ # authorization hook stops having to tell them apart:
59
+ #
60
+ # configuration.component_authorization_context = ->(controller:) { controller.current_account }
61
+ # configuration.payload_authorization_context = ->(connection:) { connection.current_account }
62
+
55
63
  # On hosts where shell access is already an authenticated administrative
56
64
  # boundary, this enables only gem commands that pass the CLI context:
57
65
  #