solid_objects 0.2.1 → 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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +261 -31
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +116 -23
  9. data/docs/authorization.md +53 -3
  10. data/docs/benchmarks.md +6 -2
  11. data/docs/correctness.md +45 -1
  12. data/docs/database-schema.md +18 -5
  13. data/docs/development.md +40 -0
  14. data/docs/fit.md +10 -2
  15. data/docs/migrating-existing-state.md +8 -1
  16. data/docs/operations.md +74 -36
  17. data/docs/realtime.md +106 -12
  18. data/docs/roadmap.md +14 -7
  19. data/docs/security.md +25 -3
  20. data/docs/state-migrations.md +2 -0
  21. data/examples/application/README.md +5 -4
  22. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  23. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  24. data/examples/application/config/initializers/solid_objects.rb +9 -3
  25. data/lib/generators/solid_objects/templates/solid_objects.rb +33 -2
  26. data/lib/solid_objects/activation.rb +33 -4
  27. data/lib/solid_objects/actor.rb +58 -6
  28. data/lib/solid_objects/actor_channel.rb +73 -5
  29. data/lib/solid_objects/actor_definition.rb +2 -0
  30. data/lib/solid_objects/actor_snapshot.rb +35 -10
  31. data/lib/solid_objects/actor_view.rb +103 -10
  32. data/lib/solid_objects/application_write_guard.rb +24 -0
  33. data/lib/solid_objects/caller_process.rb +28 -0
  34. data/lib/solid_objects/cli.rb +44 -5
  35. data/lib/solid_objects/client.rb +98 -5
  36. data/lib/solid_objects/commit_action_registry.rb +42 -0
  37. data/lib/solid_objects/component_path_resolver.rb +27 -0
  38. data/lib/solid_objects/component_registration.rb +117 -0
  39. data/lib/solid_objects/component_renderer.rb +82 -0
  40. data/lib/solid_objects/component_subscriptions.rb +109 -0
  41. data/lib/solid_objects/component_token.rb +139 -0
  42. data/lib/solid_objects/component_view.rb +67 -0
  43. data/lib/solid_objects/configuration.rb +39 -1
  44. data/lib/solid_objects/database_adapter.rb +28 -1
  45. data/lib/solid_objects/database_adapters/mysql.rb +46 -0
  46. data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
  47. data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
  48. data/lib/solid_objects/errors.rb +156 -0
  49. data/lib/solid_objects/executor.rb +65 -4
  50. data/lib/solid_objects/instance_pruner.rb +97 -0
  51. data/lib/solid_objects/message_pruner.rb +97 -0
  52. data/lib/solid_objects/message_reference.rb +9 -0
  53. data/lib/solid_objects/process_pruner.rb +49 -0
  54. data/lib/solid_objects/reference.rb +5 -0
  55. data/lib/solid_objects/state_snapshot.rb +41 -0
  56. data/lib/solid_objects/stream_token.rb +32 -13
  57. data/lib/solid_objects/sync_deadline.rb +57 -0
  58. data/lib/solid_objects/sync_diagnostics.rb +133 -0
  59. data/lib/solid_objects/synchronous_invocation.rb +26 -7
  60. data/lib/solid_objects/test_helper.rb +78 -0
  61. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  62. data/lib/solid_objects/version.rb +1 -1
  63. data/lib/solid_objects/worker.rb +1 -1
  64. data/lib/solid_objects.rb +40 -0
  65. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  66. data/sig/generated/lib/solid_objects/activation.rbs +3 -0
  67. data/sig/generated/lib/solid_objects/actor.rbs +29 -0
  68. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  69. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  70. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  71. data/sig/generated/lib/solid_objects/application_write_guard.rbs +8 -0
  72. data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
  73. data/sig/generated/lib/solid_objects/cli.rbs +11 -2
  74. data/sig/generated/lib/solid_objects/client.rbs +15 -0
  75. data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
  76. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  77. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  78. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  79. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  80. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  81. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  82. data/sig/generated/lib/solid_objects/configuration.rbs +35 -7
  83. data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
  84. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
  85. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  86. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
  87. data/sig/generated/lib/solid_objects/errors.rbs +130 -0
  88. data/sig/generated/lib/solid_objects/executor.rbs +12 -0
  89. data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
  90. data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
  91. data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
  92. data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
  93. data/sig/generated/lib/solid_objects/reference.rbs +3 -0
  94. data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
  95. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  96. data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
  97. data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
  98. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
  99. data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
  100. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  101. data/sig/generated/lib/solid_objects.rbs +12 -0
  102. metadata +34 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 654720ae19caa970f403b5cded61287e1f6a9eb9d731ff90fd9f4f103d55fa53
4
- data.tar.gz: 256712423dd88685643a0345de44eb774efbca12188c78c9e89571ef884b59fc
3
+ metadata.gz: 557108141818714af50bfee19acda22c9243482e976b50329e5e234aed6d9adb
4
+ data.tar.gz: fe525288022e4bb0630e60bc7ec949e2f61d1ad152fadcaccaeaa41bd1a0376e
5
5
  SHA512:
6
- metadata.gz: f1e43da5f13c33558bdb187b52c55b55093786d3ec8af9f49d4ffc123ade274d2f5483dd8975c6a638152dfc9ae1c4cf416d8b1ea94cb1383ac30b33ef7548aa
7
- data.tar.gz: f8cb10850b5b3748450ffaf741417866ed360437657c5d21836312018f0d6b27109cd69c4035f770f6cf643227ab086ae7eedc1b0a14245103cfe517a008f49c
6
+ metadata.gz: e4414b1f2e0d47f95f9307fec6140894bcd8ebcab6dbdc20f54155c332dea76de2728ca7b0bf71bc13c978f2494e059f2099986757c3b933296d460a2bec7a65
7
+ data.tar.gz: 6e807551d58c7d87465bc55b460386f5b9617e36021ddbdfdcbfd25aac31d9c48a7166a818f5a39be3f52eb6a7cb63efd74223a0512fb7a53dce7c55a2c23ca8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
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
+
13
+ ## 0.3.0 - 2026-08-06
14
+
15
+ - Reject application-record writes from actor handlers and provide registered
16
+ same-database commit actions for fenced atomic changes.
17
+ - Reject synchronous invocation inside an open Solid Objects transaction and
18
+ add adapter database deadlines, durable diagnostics, and recoverable results
19
+ to sync timeouts.
20
+ - Guard handlers, observables, lifecycle hooks, and state migrations from
21
+ direct application-record writes.
22
+ - Add dry-run-first bounded message, process, and opt-in actor-instance
23
+ pruning, configurable retention, and graceful caller-process shutdown.
24
+ - Add authorized committed state snapshots, mutable JSON copies, commit-action
25
+ instrumentation, and deterministic full-runtime Minitest draining.
26
+
3
27
  ## 0.2.1 - 2026-08-06
4
28
 
5
29
  - Add `solid_objects:doctor` for configuration, schema, policy, runtime, and
data/README.md CHANGED
@@ -24,6 +24,7 @@ end
24
24
  counter = Counter.ref("global")
25
25
  count = counter.increment(amount: 5)
26
26
  current_count = counter.value
27
+ current_snapshot = counter.snapshot.value
27
28
 
28
29
  # Durable fire-and-forget delivery. A worker processes it later.
29
30
  message = counter.async(:increment, amount: 5)
@@ -41,13 +42,21 @@ The invocation model is the first adoption decision:
41
42
  | --- | --- | --- |
42
43
  | `counter.increment(amount: 5)` | Committed handler result | No |
43
44
  | `counter.sync(:increment, amount: 5)` | Committed handler result | No |
44
- | `counter.value` | Deeply frozen state snapshot | No |
45
+ | `counter.value` | Ordered, committed query result | No |
46
+ | `counter.snapshot.value` | Current committed state without a mailbox message | No |
45
47
  | `counter.async(:increment, amount: 5)` | `MessageReference` immediately | Yes |
46
48
 
47
49
  Direct methods and `sync` durably enqueue the call, then the Rails caller helps
48
50
  execute the actor through the same mailbox, lease, and fencing path as a
49
51
  worker. `async` only enqueues; a runtime process handles it later.
50
52
 
53
+ Synchronous calls fail before enqueue when the Solid Objects database
54
+ connection is already inside a transaction. Actor handlers may read application
55
+ records, but direct Active Record writes are rejected so they cannot escape a
56
+ later actor failure. Use a same-database
57
+ [`commit_action`](#application-database-writes) for atomic database changes and
58
+ [`emit`](#effects) for external I/O.
59
+
51
60
  Before adopting a latency-sensitive or high-volume surface, read
52
61
  [Is Solid Objects a good fit?](docs/fit.md) and the
53
62
  [measured performance and row-growth costs](docs/benchmarks.md).
@@ -56,8 +65,8 @@ This is a port of the programming model, not Cloudflare's edge runtime or
56
65
  platform. Read the conceptual overview at [solidobjects.dev](https://solidobjects.dev/)
57
66
  and the exact Rails guarantees in [Correctness and delivery semantics](docs/correctness.md).
58
67
 
59
- Version 0.2 is an early release. Its correctness core is implemented and tested,
60
- but the project does not yet claim production readiness. See
68
+ Solid Objects is an early release. Its correctness core is implemented and
69
+ tested, but the project does not yet claim production readiness. See
61
70
  [Status](#status) and the [roadmap](docs/roadmap.md).
62
71
 
63
72
  ## Table of contents
@@ -65,10 +74,12 @@ but the project does not yet claim production readiness. See
65
74
  - [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
66
75
  - [Reactive ERB](#reactive-erb)
67
76
  - [Installation](#installation)
77
+ - [Upgrading](#upgrading)
68
78
  - [Worker requirements](#worker-requirements)
69
79
  - [Defining an actor](#defining-an-actor)
70
80
  - [Actor identity](#actor-identity)
71
81
  - [Invoking an object](#invoking-an-object)
82
+ - [Application database writes](#application-database-writes)
72
83
  - [Effects](#effects)
73
84
  - [Reminders](#reminders)
74
85
  - [Destroying an object](#destroying-an-object)
@@ -163,42 +174,105 @@ rolled-back state change cannot leak into the page.
163
174
  Define an observable:
164
175
 
165
176
  ```ruby
166
- class ShoppingCart < SolidObjects::Actor
167
- attribute :items, default: -> { [] }
177
+ class ChatRoom < SolidObjects::Actor
178
+ attribute :recent_messages, default: -> { [] }
179
+ attribute :status, default: "open"
168
180
 
169
- observable :items_count do
170
- items.sum { |item| item.fetch("quantity") }
181
+ observable :message_count do
182
+ recent_messages.length
171
183
  end
184
+
185
+ observable :recent_messages
186
+ observable :status
172
187
  end
173
188
  ```
174
189
 
175
- Render it:
190
+ Scalar observables remain stable `<span>` targets:
176
191
 
177
192
  ```erb
178
- <%= solid_object current_cart do |cart| %>
179
- Cart items: <%= cart.items_count %>
193
+ <%= solid_object @room, authorization_context: current_user do |room| %>
194
+ Messages: <%= room.message_count %>
180
195
  <% end %>
181
196
  ```
182
197
 
183
- That template provides initial server rendering, a stable opaque DOM target,
184
- and live Turbo replacements after committed actor turns. One `solid_object`
185
- block makes one Action Cable subscription for all values inside it, and Action
186
- Cable multiplexes subscriptions over the browser's WebSocket.
198
+ Reactive components rerender a host ERB partial when one of their explicit
199
+ dependencies changes:
200
+
201
+ ```erb
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] %>
205
+ <% end %>
206
+ ```
207
+
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.
187
230
 
188
231
  No client-side state store, custom Stimulus controller, channel class, manual
189
232
  broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
190
- protect integrity, an application policy authorizes every subscription,
191
- broadcasts are delivered from a durable outbox, and reconnecting clients
192
- refresh from current actor state.
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:
237
+
238
+ ```ruby
239
+ SolidObjects.configure do |configuration|
240
+ configuration.component_authorization_context = ->(controller:) { Current.user }
241
+ end
242
+ ```
193
243
 
194
- `cart.component(:summary)` supports initial rendering of
195
- `actors/shopping_cart/_summary`. Durable live component replacement and
196
- Turbo append actions are roadmap work; observable replacement is the live path
197
- implemented in 0.2.
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.
198
266
 
199
267
  Reactive views require `turbo-rails` and a working Action Cable adapter in the
200
- host application. They are optional; the actor runtime itself does not depend
201
- on Turbo.
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
+ ```
202
276
 
203
277
  ## Installation
204
278
 
@@ -264,6 +338,50 @@ can generate the gem RBI with:
264
338
  bundle exec tapioca gem solid_objects
265
339
  ```
266
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
+
267
385
  ## Worker requirements
268
386
 
269
387
  Synchronous actors can be adopted without adding a long-running process. Start
@@ -273,13 +391,14 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
273
391
  | --- | --- |
274
392
  | Direct actor method or explicit `sync` | None; the caller executes it |
275
393
  | Attribute or declared query read | None; the caller executes it |
394
+ | Committed `snapshot` read | None; reads the instance row directly |
276
395
  | `destroy` | None |
277
396
  | `async` including delayed delivery | Actor worker |
278
397
  | One-shot or recurring `schedule` | Reminder scheduler and actor worker |
279
398
  | `emit` without an actor callback | Effect worker |
280
399
  | `emit` with success or failure callback | Effect worker and actor worker |
281
400
  | Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
282
- | Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
401
+ | Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
283
402
  | Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
284
403
 
285
404
  One command starts every Solid Objects role:
@@ -346,6 +465,20 @@ mailbox. State changes must go through public actor methods or explicit
346
465
  State, arguments, results, effects, and reminder arguments accept
347
466
  JSON-compatible values. Solid Objects never deserializes Ruby `Marshal` data.
348
467
 
468
+ Attribute readers are ordered mailbox queries and retain message history. For
469
+ a read that does not need mailbox ordering, use an authorized committed
470
+ snapshot:
471
+
472
+ ```ruby
473
+ snapshot = cart.snapshot
474
+ items = snapshot.items
475
+ ```
476
+
477
+ Snapshots and synchronous results are deeply frozen. Use
478
+ `SolidObjects.mutable_copy(items)` before changing a returned collection.
479
+ Snapshot reads can race with an in-flight turn; they return the most recently
480
+ committed state and do not create or activate a missing actor.
481
+
349
482
  Lifecycle hooks are also available:
350
483
 
351
484
  ```ruby
@@ -451,6 +584,37 @@ and MCP request/response boundaries when the handler itself fits the
451
584
  application's latency budget. If another process owns the activation, the
452
585
  caller waits for the durable result using wake-up hints with bounded database
453
586
  polling as the fallback. A timeout never cancels the durable invocation.
587
+ `SolidObjects::SyncTimeout` includes actor identity, message ID, sequence,
588
+ durable status, mailbox blocker, and activation-owner diagnostics without
589
+ including message arguments. The configured timeout also bounds adapter
590
+ database lock waits from the enqueue attempt through result observation.
591
+ PostgreSQL uses transaction lock and statement timeouts, SQLite uses its busy
592
+ timeout, and MySQL uses its execution timeout plus InnoDB's one-second minimum
593
+ lock-wait granularity.
594
+
595
+ The durable call can finish after its original caller gives up. Reauthorize and
596
+ recover its eventual result through the durable message identity:
597
+
598
+ ```ruby
599
+ begin
600
+ order.submit(timeout: 250.milliseconds)
601
+ rescue SolidObjects::SyncTimeout => error
602
+ result = error.message_reference.wait(
603
+ timeout: 5.seconds,
604
+ authorization_context: Current.user
605
+ )
606
+ end
607
+ ```
608
+
609
+ If the enqueue transaction itself cannot finish within the budget, Solid
610
+ Objects raises `SyncEnqueueTimeout`; no durable message exists to recover.
611
+ Timeouts do not preempt Ruby handler code that has already started.
612
+
613
+ Do not wrap a synchronous actor call in `ApplicationRecord.transaction`.
614
+ Solid Objects raises `SolidObjects::SyncInsideTransaction` before enqueue when
615
+ its connection already has an open transaction. Move the actor call before the
616
+ transaction, use `async`, or let the actor own the coordinated change through a
617
+ commit action.
454
618
 
455
619
  Actor code cannot use direct calls or `sync` on another actor; synchronous
456
620
  actor-to-actor waits can deadlock in cycles. Use `async` or `send_to` and a
@@ -492,6 +656,50 @@ end
492
656
 
493
657
  External systems must also deduplicate effects using the stable effect ID.
494
658
 
659
+ ## Application database writes
660
+
661
+ Actor handlers execute outside the fenced commit. They may query application
662
+ records, but Solid Objects rejects direct Active Record writes from all
663
+ user-supplied actor code: handlers, observables, activation/deactivation hooks,
664
+ and state migrations. Otherwise an application row could commit before the
665
+ actor later raises or loses its activation fence.
666
+
667
+ For a short database-only change that must commit atomically with actor state,
668
+ stage a named action:
669
+
670
+ ```ruby
671
+ class Assessment < SolidObjects::Actor
672
+ attribute :status, default: "open"
673
+
674
+ def finish(attempt_id:, score:)
675
+ self.status = "complete"
676
+ commit_action :complete_attempt, attempt_id:, score:
677
+ end
678
+ end
679
+ ```
680
+
681
+ Register its implementation during application boot:
682
+
683
+ ```ruby
684
+ SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
685
+ AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
686
+ score: arguments.fetch("score"),
687
+ actor_message_id: context.message_id
688
+ )
689
+ end
690
+ ```
691
+
692
+ The registered block runs inside the short fenced transaction. Its database
693
+ writes, actor state, message completion, and outboxes all commit or roll back
694
+ together. Commit actions require Solid Objects and `ActiveRecord::Base` to
695
+ share one connection pool. They may be invoked again after a database rollback,
696
+ so keep them deterministic, bounded, and database-only. Never perform network
697
+ I/O, wait for another actor, or enqueue nontransactional work from a commit
698
+ action.
699
+
700
+ When Solid Objects uses a separate actor database, use `emit` and an idempotent
701
+ effect consumer instead; the two databases cannot share one transaction.
702
+
495
703
  ## Effects
496
704
 
497
705
  Cloudflare Durable Objects can call external services directly. Solid Objects
@@ -555,6 +763,10 @@ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
555
763
  `.orphaned`, but every repair must go through `async`. Never bulk-update actor
556
764
  state around the lease and fencing checks.
557
765
 
766
+ Suspended actors should be reported rather than silently resumed. Spread large
767
+ repair batches with `available_at:` so reconciliation cannot stampede one
768
+ mailbox or the worker fleet.
769
+
558
770
  ## Destroying an object
559
771
 
560
772
  Destroy an actor incarnation through its reference:
@@ -631,6 +843,11 @@ Important defaults:
631
843
  | `max_attempts` | 5 |
632
844
  | `process_heartbeat_interval` | 15 seconds |
633
845
  | `process_alive_threshold` | 60 seconds |
846
+ | `message_retention` | 30 days |
847
+ | `message_retention_by_actor_type` | `{}` |
848
+ | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
849
+ | `process_retention` | 7 days |
850
+ | `prune_batch_size` | 1,000 |
634
851
  | `worker_count` | 1 |
635
852
  | `effect_worker_count` | 1 |
636
853
  | `broadcast_worker_count` | 1 |
@@ -664,10 +881,16 @@ Administration commands require the administration policy:
664
881
  ```bash
665
882
  bundle exec solid_objects status
666
883
  bundle exec solid_objects cleanup
884
+ bundle exec solid_objects prune_messages
885
+ bundle exec solid_objects prune_instances
886
+ bundle exec solid_objects prune_processes
667
887
  bundle exec solid_objects dead_letters
668
888
  bundle exec solid_objects retry_dead_letter 123
669
889
  ```
670
890
 
891
+ The prune commands preview counts by default. Add `--execute` only after
892
+ reviewing the configured retention policy.
893
+
671
894
  The supervisor stops new claims, drains active loops, releases cached leases,
672
895
  and marks process rows stopped on graceful shutdown. A hard-killed worker's
673
896
  claimed turn is recovered after its process heartbeat or activation lease
@@ -814,11 +1037,13 @@ See the [development guide](docs/development.md) and
814
1037
 
815
1038
  ## Status
816
1039
 
817
- Implemented and tested in 0.2:
1040
+ Implemented and tested in 0.4:
818
1041
 
819
1042
  - Rails engine, install generator, migrations, and `solid_objects` executable;
820
1043
  - actor registry, references, JSON state, and state migrations;
821
1044
  - direct synchronous actor RPC, explicit `sync`, and durable `async`;
1045
+ - guarded transaction boundaries, same-database commit actions, adapter lock
1046
+ deadlines, structured synchronous timeout diagnostics, and result recovery;
822
1047
  - durable message history plus ready and claimed membership tables;
823
1048
  - concurrent sequence allocation and actor creation;
824
1049
  - activation leases, per-activation tokens, fencing generations, and
@@ -830,8 +1055,13 @@ Implemented and tested in 0.2:
830
1055
  - one-shot and recurring per-actor reminders;
831
1056
  - authorized actor destruction with fenced stale-write rejection and cascading
832
1057
  durable-work cleanup;
833
- - durable observable broadcasts and authorized Action Cable refresh;
834
- - process registration, heartbeats, cleanup, and graceful shutdown; and
1058
+ - durable observable invalidations, scalar Turbo replacement, and authorized
1059
+ request-time ERB component refresh;
1060
+ - process registration, heartbeats, caller shutdown, cleanup, and bounded
1061
+ message/process retention plus opt-in actor-instance expiration;
1062
+ - an opt-in Minitest helper for actor-state isolation and deterministic async
1063
+ actor/reminder/effect/broadcast draining;
1064
+ - authorized mailbox-free state snapshots and mutable JSON copies; and
835
1065
  - SQLite, PostgreSQL, and MySQL integration tests.
836
1066
 
837
1067
  Partially implemented:
@@ -840,12 +1070,12 @@ Partially implemented:
840
1070
  run periodic maintenance automatically;
841
1071
  - cross-process wake-up uses polling; PostgreSQL notifications and optional
842
1072
  Redis acceleration are not implemented;
843
- - live observable replacement works, while live component replacement and
844
- Turbo append actions remain future work;
1073
+ - live observable and component replacement work, while Turbo append actions
1074
+ remain future work;
845
1075
  - local admission limits exist, but distributed rate limits and global
846
1076
  admission control do not; and
847
- - administration views exist, but retention automation and richer audit tools
848
- do not.
1077
+ - administration views and pruning commands exist, but scheduled maintenance
1078
+ and richer audit tools do not.
849
1079
 
850
1080
  Production readiness requires hardening and operational soak evidence. The
851
1081
  [roadmap](docs/roadmap.md) tracks that work.
@@ -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
@@ -1,6 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  SolidObjects::Engine.routes.draw do
4
+ get :components, to: "components#show"
4
5
  resources :instances, only: %i[index show]
5
6
  resources :dead_letters, only: %i[index] do
6
7
  post :retry, on: :member
@@ -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