solid_objects 0.9.0 → 0.10.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +2 -1
  4. data/app/controllers/solid_objects/components_controller.rb +3 -18
  5. data/app/models/solid_objects/instance.rb +31 -4
  6. data/benchmark/support.rb +47 -5
  7. data/docs/architecture.md +4 -0
  8. data/docs/benchmarks.md +19 -2
  9. data/docs/local-testing.md +7 -0
  10. data/docs/realtime.md +59 -4
  11. data/docs/roadmap.md +49 -28
  12. data/exe/solid_objects +12 -1
  13. data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
  14. data/lib/solid_objects/actor_channel.rb +50 -10
  15. data/lib/solid_objects/callable_keywords.rb +29 -0
  16. data/lib/solid_objects/component_subscriptions.rb +19 -11
  17. data/lib/solid_objects/configuration.rb +13 -0
  18. data/lib/solid_objects/database_adapter.rb +24 -4
  19. data/lib/solid_objects/database_adapters/mysql.rb +17 -1
  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.rb +1 -1
  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 +10 -0
  31. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +10 -0
  32. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
  33. data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
  34. data/sig/generated/models/solid_objects/instance.rbs +11 -0
  35. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7fee6db10af36996dbe9c8484fab405b53e2463a3fd7d2f7414310fedab68d5
4
- data.tar.gz: 8b71bb8fbf0dc0359ce8a00097428dbbcd189e4d2b4071ba0339b9c3ec1cd933
3
+ metadata.gz: 28a727648d2afc1ed0cd5be03fd624617db444bf173b289327d8c69e2a4abf80
4
+ data.tar.gz: 0f3bb956b7dcda6f993c92d3ce7f24a3ecf93caf0435030fe2e44955eb581c22
5
5
  SHA512:
6
- metadata.gz: 62f2ce7ecfa4899f82af3682c5a8fb97d7005e6ba9152a8a63433b2ec345d2edc3d795c29a7f6de24d25f275afd4857986ed34302359d0f3aab018f936656acd
7
- data.tar.gz: 2114e1d8b7757bf598562fba6e1b8ecafeea852d6925a5161f74b1dcab6dab9b5750ca71327b0cfa23b927869cbeee348f1bd6d03cbfc5d964d2bfd53a625435
6
+ metadata.gz: 6d5ffbb3074eff11b32966560a2ca42dca37dc7c8c9cf1725a9965a40815752379be88f18b6b0b184592931d180f7248d6c01e5344be2b79a29cdde5377ab23f
7
+ data.tar.gz: ca700bcc8d86a6939c462be3003b0e649c6a2611289b12064dda9d0f6e3f844cc332e479b355b248e0a97c9073e537b1b99a8ae0cfd7ac454b170bd03bbd9a71
data/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1 - 2026-08-10
4
+
5
+ - Support Trilogy. Adapter selection matched the client name rather than the
6
+ protocol, and Trilogy reports `"Trilogy"`, so every Solid Objects call raised
7
+ `UnsupportedDatabase: unsupported database adapter "Trilogy"` on a database
8
+ the gem fully supports. Adapter names now resolve through one table of
9
+ families, `DatabaseAdapter.family`, used by adapter selection, owner-id
10
+ casting, and wake-up adapter selection alike, so a client cannot be accepted
11
+ in one place and rejected in another.
12
+ - Compare reconciliation owner ids in the column's own collation.
13
+ `Instance.orphaned` cast owner primary keys to `CHAR`, and a cast result
14
+ carries the connection collation rather than the column's. MySQL refuses to
15
+ compare two collations, so the query raised `Illegal mix of collations`
16
+ whenever the two differed. That is a property of the client rather than the
17
+ schema: mysql2 negotiates the database default while Trilogy negotiates
18
+ `utf8mb4_general_ci`. A mysql2 application that set `collation:` in
19
+ `database.yml` could already hit this.
20
+ - Recognise a statement interruption from any MySQL client. A synchronous
21
+ deadline is enforced by asking the server to interrupt the statement, and the
22
+ interruption was matched only through mysql2's `error_number`. Trilogy names
23
+ it `error_code`, so a deadline surfaced as a raw
24
+ `ActiveRecord::StatementTimeout` instead of `SyncEnqueueTimeout`. Both names
25
+ are read, and Active Record's own classification is trusted first.
26
+ - Run the MySQL suite against both mysql2 and Trilogy in CI, and key
27
+ adapter-specific test skips to the database family rather than the client
28
+ name, so a Trilogy run no longer silently skips every MySQL test.
29
+
30
+ ## 0.10.0 - 2026-08-10
31
+
32
+ - Report a denied CLI command as a policy decision rather than a crash.
33
+ Administration denies by default, so an unconfigured host met a thirty-line
34
+ Ruby backtrace on its first `solid_objects` command. The executable now
35
+ prints the refusal and the setting that grants access, and exits 1.
36
+ - Measure the query count for a synchronous call. `benchmark/query_count.rb`
37
+ only measured a worker turn, so the documented synchronous number had no
38
+ script behind it. It reports three now: a message turn costs 26 queries
39
+ rather than the documented 29, the caller of a synchronous call costs 49, and
40
+ a synchronous call in total costs 75, being a caller plus the turn it waits
41
+ on. Counting is scoped to the measuring thread, since a worker loop polls
42
+ whether or not a call is in flight and a process-wide count folds those polls
43
+ into the result.
44
+
45
+ - Run payload broadcast blocks against the actor instance, like every other
46
+ block in the actor DSL. `self` was the actor class, so an actor instance
47
+ method called from a payload block raised
48
+ `NoMethodError: undefined method 'x' for class PlaymatRoom`. Blocks keep
49
+ receiving the actor and the authorization context as arguments, so the
50
+ documented signature is unaffected. A block that relied on the class receiver
51
+ now raises `InvalidPayloadBroadcast` naming the method and the change instead
52
+ of an unexplained `NameError`.
53
+ - Add `payload_authorization_context`, the payload counterpart to
54
+ `component_authorization_context`. Payloads are computed inside the channel,
55
+ so without a resolver the payload block and its `authorize_query` call
56
+ received the raw Action Cable connection while a controller render passed an
57
+ application object, and the authorization hook had to tell them apart. The
58
+ resolver may also accept `payload_name:`. It defaults to returning the
59
+ connection unchanged.
60
+ - Confine a failing payload to itself. A raising payload block propagated out of
61
+ the channel: on subscribe it rejected the subscription, and on a broadcast it
62
+ abandoned the remaining payload names, which showed up in the browser only as
63
+ reactive updates that stopped arriving. A failure is now reported as
64
+ `solid_objects.payload_broadcast_failed` with the actor type, actor id,
65
+ payload name, and exception class, and delivery continues. The exception
66
+ message is deliberately excluded so subscriber state cannot leak into logs. A
67
+ revision with a failed payload does not advance the delivery watermark, so a
68
+ transient failure is retried on the next broadcast instead of being recorded
69
+ as delivered and deduplicated away.
70
+ - Run retention on the supervisor rather than leaving it configured but
71
+ unscheduled. Every actor call writes a durable message row, so a policy that
72
+ nothing invokes let history grow without bound until an application scheduled
73
+ its own job. `retention_interval` defaults to one hour, and zero disables it.
74
+ Retention runs on its own thread, so a slow pass cannot delay replacing a
75
+ crashed role, and a failed pass retries at monitor cadence with a doubling
76
+ backoff rather than deferring for the whole interval.
77
+ - Batch component refreshes on reconnect. A reconnecting subscription refreshed
78
+ every stale component individually, ignoring the batches those components
79
+ declared, so a page with twenty batched components issued twenty requests
80
+ instead of one. That happens at the worst moment: a server restart reconnects
81
+ every client at once. Reconnect now shares the batching the live invalidation
82
+ path uses.
83
+ - Cover the reconnect burst in the browser suite: convergence of batched and
84
+ unbatched components, an inert replay of an already-applied revision,
85
+ cancellation of the request left in flight by the drop, incarnation ordering
86
+ after a destroy and recreate, and payload delivery exactly once per revision.
87
+ - Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
88
+ 4.0 against Rails 8.0 and 8.1.
89
+
3
90
  ## 0.9.0 - 2026-08-10
4
91
 
5
92
  - Add a browser test suite running the refresh modules against real Chromium and
data/README.md CHANGED
@@ -956,7 +956,8 @@ shutdown, retention, and backup guidance.
956
956
  Solid Objects supports:
957
957
 
958
958
  - PostgreSQL 14 or newer
959
- - MySQL 8.0 or newer using InnoDB
959
+ - MySQL 8.0 or newer using InnoDB, through either the `mysql2` or `trilogy`
960
+ client
960
961
  - SQLite 3.35 or newer
961
962
 
962
963
  PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.
@@ -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]
@@ -56,7 +56,7 @@ module SolidObjects
56
56
  )
57
57
  owner_ids = owner_relation
58
58
  .except(:select)
59
- .select(cast_id)
59
+ .select(collated(cast_id))
60
60
 
61
61
  where(actor_type:).where.not(actor_id: owner_ids)
62
62
  end
@@ -70,12 +70,39 @@ module SolidObjects
70
70
 
71
71
  private
72
72
 
73
+ # A cast result carries the connection collation, not the column's, and
74
+ # MySQL refuses to compare two collations. Which collation a connection
75
+ # uses is a property of the client rather than the schema: mysql2
76
+ # negotiates the database default while Trilogy negotiates
77
+ # utf8mb4_general_ci, so the comparison is pinned to the column's own.
78
+ # @rbs (untyped) -> untyped
79
+ def collated(node)
80
+ collation = owner_id_collation
81
+ return node unless collation
82
+
83
+ Arel::Nodes::InfixOperation.new(
84
+ "COLLATE",
85
+ node,
86
+ Arel::Nodes::SqlLiteral.new(collation)
87
+ )
88
+ end
89
+
90
+ # @rbs () -> String?
91
+ def owner_id_collation
92
+ return nil unless DatabaseAdapter.family(connection) == :mysql
93
+
94
+ collation = columns_hash["actor_id"]&.collation
95
+ return nil unless collation&.match?(/\A[a-zA-Z0-9_]+\z/)
96
+
97
+ collation
98
+ end
99
+
73
100
  # @rbs () -> String
74
101
  def owner_id_cast_type
75
- case connection.adapter_name
76
- when /mysql/i
102
+ case DatabaseAdapter.family(connection)
103
+ when :mysql
77
104
  "CHAR"
78
- when /postgres/i
105
+ when :postgresql
79
106
  "VARCHAR"
80
107
  else
81
108
  "TEXT"
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/architecture.md CHANGED
@@ -41,6 +41,10 @@ The host application owns:
41
41
  ## Database coordination adapters
42
42
 
43
43
  Solid Objects supports PostgreSQL 14+, MySQL 8.0+ with InnoDB, and SQLite 3.35+.
44
+ MySQL is reached through either the `mysql2` or `trilogy` client. Adapter names
45
+ are client names rather than protocol names, so every decision that depends on
46
+ the database resolves through `DatabaseAdapter.family` rather than matching an
47
+ adapter name in place.
44
48
 
45
49
  One adapter capability object is selected from the Active Record connection. It supplies claim locking and database-time expressions. Unsupported adapter families fail when first used. Minimum server-version and storage-engine checks are documented operating requirements; automatic boot-time enforcement and classified contention retries remain hardening work.
46
50
 
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
@@ -48,10 +48,17 @@ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
48
48
  SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
49
49
  bundle exec rake test
50
50
 
51
+ SOLID_OBJECTS_DATABASE_URL=trilogy://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
52
+ bundle exec rake test
53
+
51
54
  SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
52
55
  bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
53
56
  ```
54
57
 
58
+ Run both MySQL clients. They report different adapter names, negotiate
59
+ different connection collations, and name the same error code differently, so a
60
+ pass on one says nothing about the other. Recreate the database between them.
61
+
55
62
  Stop them with `docker rm -f so-mysql so-redis`.
56
63
 
57
64
  ## Recreating a database between runs
data/docs/realtime.md CHANGED
@@ -237,9 +237,11 @@ single actor mutation changes several components, an application pays several
237
237
  round trips for one logical update. A payload broadcast collapses that into one
238
238
  message on the stream the page already has open.
239
239
 
240
- Declare the payload on the actor. The block receives the actor and the
241
- subscriber's authorization context, and it runs **once per subscriber**, so two
242
- 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:
243
245
 
244
246
  ```ruby
245
247
  class PlaymatRoom < SolidObjects::Actor
@@ -259,6 +261,16 @@ class PlaymatRoom < SolidObjects::Actor
259
261
  end
260
262
  ```
261
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
+
262
274
  Subscribe the scope to it:
263
275
 
264
276
  ```erb
@@ -298,6 +310,26 @@ Payload blocks read committed actor state through the same snapshot components
298
310
  use. They cannot write application records, and the return value must be a JSON
299
311
  object or array so the wire format stays inspectable.
300
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
+
301
333
  ### mtg-playmat before and after
302
334
 
303
335
  Before, one mutation that touched three observables produced three refresh
@@ -350,13 +382,31 @@ end
350
382
  Callbacks that accept only `controller:` continue to work; the extra keyword is
351
383
  passed only to callables that declare it.
352
384
 
353
- 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:
354
403
 
355
404
  | Boundary | Authorization context |
356
405
  | --- | --- |
357
406
  | Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
358
407
  | Action Cable subscription | The authenticated Cable connection |
359
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 |
360
410
 
361
411
  Do not substitute a signed token for any of them. Keys and locals are visible
362
412
  to the browser and signed for integrity, not encrypted or authorized. Never
@@ -393,6 +443,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
393
443
  the last transmitted pair are ignored. The durable state row remains source of
394
444
  truth.
395
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
+
396
451
  The component endpoint rejects a requested revision newer than the committed
397
452
  snapshot. This is a final server-side guard; browser safety primarily comes
398
453
  from monotonic channel filtering plus replace-frame detachment or morph
data/docs/roadmap.md CHANGED
@@ -13,14 +13,21 @@
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
@@ -35,8 +42,12 @@
35
42
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
36
43
  helper
37
44
  - Supervisor role replacement: a role whose thread dies is restarted until
38
- shutdown is requested, and dead process records are pruned on an interval
39
- - SQLite, PostgreSQL, and MySQL integration suites
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
48
+ - SQLite, PostgreSQL, and MySQL integration suites, with MySQL run against both
49
+ the `mysql2` and `trilogy` clients because an adapter name, a connection
50
+ collation, and an error code name all differ between them
40
51
  - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
41
52
  a listening connection per waiting thread and release on supervisor shutdown
42
53
  - Opt-in cross-process wake-up on Redis, the option for MySQL applications,
@@ -44,51 +55,61 @@
44
55
  gem's dependencies
45
56
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
46
57
  RuboCop policy, and a warning-free Brakeman scan
47
- - Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
48
- and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
49
- rather than assumed
58
+ - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
59
+ Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
60
+ verified rather than assumed
50
61
  - A JavaScript suite covering every browser module, run in CI with Node's test
51
62
  runner and jsdom, plus a browser suite running the same modules against real
52
63
  Chromium and a real Turbo build, with every GitHub Actions reference pinned to
53
- a commit SHA
64
+ a commit SHA. The browser suite covers the reconnect burst: convergence of
65
+ batched and unbatched components, an inert replay of an applied revision,
66
+ cancellation of the request left in flight by the drop, incarnation ordering
67
+ after a destroy and recreate, and payload delivery exactly once per revision
54
68
 
55
69
  ## Partially implemented
56
70
 
57
- - Wake-up strategy: in-process signaling, durable polling, injection, and an
58
- opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
59
- not. In-process signaling cannot cross process boundaries, so without the
60
- adapter a commit in a web process does not wake a broadcast executor in a
61
- worker process and that delivery waits up to `polling_interval`, 100 ms by
62
- default. `WakeUpAdapters.for` removes that delay on PostgreSQL, measured at
63
- 103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
64
- connection per waiting thread outside the pool, and `LISTEN` does not survive
65
- a transaction-pooling proxy such as PgBouncer. MySQL has no notification
66
- primitive, so MySQL applications keep polling unless they configure the Redis
67
- adapter.
71
+ - Wake-up strategy: in-process signaling, durable polling, injection, and
72
+ cross-process adapters for PostgreSQL and Redis are implemented and tested.
73
+ What is not done is making any of them automatic. In-process signaling cannot
74
+ cross process boundaries, so by default a commit in a web process does not
75
+ wake a broadcast executor in a worker process and that delivery waits up to
76
+ `polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
77
+ to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
78
+ opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
79
+ thread outside the pool and `LISTEN` does not survive a transaction-pooling
80
+ proxy such as PgBouncer, and Redis is not a dependency of this gem.
81
+ `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
82
+ default elsewhere; it never selects Redis. An application that configures
83
+ nothing keeps polling, and MySQL applications keep polling unless they
84
+ configure Redis explicitly.
68
85
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
69
86
  morphing, personalized refresh authorization, revision fencing, coalescing,
70
87
  reconnect convergence, batched refreshes, and personalized state payloads are
71
88
  implemented; application-directed Turbo append intents are not. Batch
72
89
  coalescing happens in the browser rather than the broadcast executor, so one
73
90
  commit still sends one Action Cable message per changed observable even
74
- though it costs one browser request.
91
+ though it costs one browser request. Reconnect convergence previously
92
+ bypassed batching entirely, issuing one request per stale component at the
93
+ moment a restart reconnects every client at once; it now shares the batching
94
+ the live invalidation path uses. Payload delivery over Action Cable was
95
+ untested end to end, which is how a raising payload block came to reject the
96
+ subscription; it is now covered and confined, and the payload authorization
97
+ context is resolved through `payload_authorization_context` rather than
98
+ handing the block a raw Cable connection.
75
99
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
76
100
  distributed per-actor rate limits and global admission control do not.
77
101
  - Administration: actor and dead-letter views plus policy hooks exist; richer
78
102
  filtering, audit records, and bulk-safe tools do not.
79
- - Outboxes use portable status rows with polling indexes; future versions may
80
- introduce narrow ready/claimed membership tables for very large outboxes.
81
103
 
82
104
  ## Next milestones
83
105
 
84
106
  1. Add result lookup by request ID and broader deadlock retry classification.
85
- 2. Add scheduled retention and stale-process maintenance.
86
- 3. Add Turbo append intents and expand reconnect coverage in a full browser.
87
- 4. Add distributed rate limits, global admission hooks, and cache-capacity
107
+ 2. Add Turbo append intents.
108
+ 3. Add distributed rate limits, global admission hooks, and cache-capacity
88
109
  eviction.
89
- 5. Expand security scanning. Compatibility CI across supported Rails and Ruby
90
- versions is implemented; Ruby 4.0 is not yet in the matrix.
91
- 6. Benchmark all workloads under documented hardware/database settings and
110
+ 4. Expand security scanning beyond the Brakeman scan, such as dependency
111
+ auditing and secret scanning.
112
+ 5. Benchmark all workloads under documented hardware/database settings and
92
113
  publish adapter-specific adoption measurements. Throughput, synchronous
93
114
  latency, query counts, and the three reactive delivery paths are measured on
94
115
  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
  #