upkeep-rails 0.1.6

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6cae112c7078916e9b74ece78732a3de409204ee2308b4b111636cde095721a6
4
+ data.tar.gz: f0987cb4abbf9eb27c7a95f86ef000ce3c9a601ed7944650be106a74a89fd05d
5
+ SHA512:
6
+ metadata.gz: 262f0b678a41a6ca38f0eafde117fbb9cb0f1d28f1d69c98a8d1a91c5267e42dfa04856b68763962801fc32a3858fb2545133eaefaa46192481416e106e8832a
7
+ data.tar.gz: 96780c4ee5232673d4f0ab85ed3612e196d0bba88ae23113a1487d42a24a94ae0a7e03b9b47638dddc9c7e6812ea24364d89bd7c43404031f73e97d995d226d7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Anjos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,614 @@
1
+ # Upkeep Rails
2
+
3
+ Upkeep Rails refreshes ordinary Rails pages when the data, request inputs, or
4
+ identity values they used change.
5
+
6
+ A successful HTML GET captures what the page rendered. A later Active Record
7
+ commit emits facts about what changed. Upkeep matches those facts to affected
8
+ rendered frames and delivers Turbo Stream updates over ActionCable.
9
+
10
+ The design goal is Rails-shaped DX: controllers load state, views render ERB,
11
+ models commit writes, and Upkeep derives the reactive boundary from the Rails
12
+ surfaces it observes. There is no query catalog and no `watch` or `track` DSL.
13
+ For user-specific pages, apps declare only the bridge between an observed
14
+ render-time identity and the matching ActionCable connection identity.
15
+
16
+ ## Why Upkeep
17
+
18
+ In ordinary Rails and Turbo code, the write can stay in the controller and the
19
+ stream response can live in a template. The flow still has to name every stream
20
+ target, counter, partial, or page region that might now be stale:
21
+
22
+ ```ruby
23
+ # app/controllers/cards_controller.rb
24
+ class CardsController < ApplicationController
25
+ def update
26
+ @card = Card.find(params[:id])
27
+ @card.update!(card_params)
28
+
29
+ @board = @card.board
30
+ @open_card_count = @board.cards.open.count
31
+
32
+ respond_to do |format|
33
+ format.turbo_stream
34
+ format.html { redirect_to @board }
35
+ end
36
+ end
37
+ end
38
+ ```
39
+
40
+ ```erb
41
+ <%# app/views/cards/update.turbo_stream.erb %>
42
+ <%= turbo_stream.replace dom_id(@card),
43
+ partial: "cards/card",
44
+ locals: { card: @card } %>
45
+
46
+ <%= turbo_stream.update "open_card_count", @open_card_count %>
47
+ ```
48
+
49
+ That works, but the update flow is coupled to the UI it happens to refresh.
50
+ Adding another dependent page, sidebar, filter, or counter means revisiting old
51
+ stream templates, controller assignments, callbacks, or broadcasts.
52
+
53
+ With Upkeep, the controller performs the domain action and stops:
54
+
55
+ ```ruby
56
+ class CardsController < ApplicationController
57
+ def update
58
+ Card.find(params[:id]).update!(card_params)
59
+ head :ok
60
+ end
61
+ end
62
+ ```
63
+
64
+ The GET that rendered the page already recorded which templates, collections,
65
+ records, request values, and identity values the response used. When the commit
66
+ lands, Upkeep selects the affected frames, rerenders the narrowest proven
67
+ target, and leaves unrelated subscribers alone.
68
+
69
+ ## Core Concepts
70
+
71
+ ### Rendered Page
72
+
73
+ A **rendered page** is a successful HTML GET that Upkeep can keep fresh. The
74
+ request runs normally through Rails. Upkeep observes the controller, Action View
75
+ rendering, Active Record reads, request inputs, and identity inputs used by the
76
+ response.
77
+
78
+ ### Frame
79
+
80
+ A **frame** is a rendered page, template, partial, collection render site, or
81
+ fragment with a stable delivery target. Frames let Upkeep refresh a specific
82
+ part of the page instead of replaying the whole response when a narrower update
83
+ is proven safe.
84
+
85
+ ### Surface
86
+
87
+ A **surface** is the set of facts about future writes that would make a frame
88
+ stale. For Active Record, Upkeep derives surfaces from observed record
89
+ attributes, rendered collections, and relation shape where Rails exposes a
90
+ structural Arel query.
91
+
92
+ For example, a rendered collection of open cards ordered by position produces a
93
+ surface tied to the cards table, the columns that decide membership, and the
94
+ records rendered in that collection. A card update only selects the frames whose
95
+ surface it can affect.
96
+
97
+ ### Identity Boundary
98
+
99
+ An **identity boundary** is state that decides who may receive a live update.
100
+ Upkeep records observed CurrentAttributes, Warden, session, cookie, and request
101
+ reads for replay and sharing, but it does not infer subscriber identity by
102
+ naming convention. Subscriber identity must be declared with
103
+ `config.identify`, then resolved again from ActionCable when the browser
104
+ subscribes.
105
+
106
+ ### Subscription
107
+
108
+ A **subscription** is the browser's live connection back to the captured page.
109
+ Upkeep injects a body-scoped `<upkeep-subscription-source>` marker into
110
+ successful HTML responses. The generated browser bootstrap upgrades that marker
111
+ into a Turbo stream source, subscribes over ActionCable, and lets Turbo process
112
+ received stream payloads.
113
+
114
+ ### Proven Delivery
115
+
116
+ **Proven delivery** means Upkeep only emits the narrowest Turbo operation it can
117
+ justify. It may `append`, `prepend`, `remove`, `replace`, `update`, or issue a
118
+ Turbo page `refresh` depending on the proof available. If Upkeep cannot prove a
119
+ boundary, it refuses registration instead of silently widening into unsafe broad
120
+ invalidation.
121
+
122
+ Render-site replays use Turbo Stream `update method="morph"` against the real
123
+ HTML element Upkeep marked as the render site. The stream template is the render
124
+ site's children, so `update` preserves the legal container element and swaps its
125
+ contents. Page-level fallbacks use Turbo Stream
126
+ `refresh method="morph" scroll="preserve"` instead of replacing `<html>` or
127
+ writing a new document from JavaScript.
128
+
129
+ ## Quick Start
130
+
131
+ ### 1. Add Upkeep
132
+
133
+ Add the gem to a Rails app:
134
+
135
+ ```ruby
136
+ gem "upkeep-rails"
137
+ ```
138
+
139
+ The Railtie installs hooks when Rails loads Active Record, Action Controller,
140
+ and Action View. The runtime is enabled by default.
141
+
142
+ ### 2. Run The Installer
143
+
144
+ ```sh
145
+ bin/rails generate upkeep:install
146
+ bin/rails db:migrate
147
+ ```
148
+
149
+ The generator creates subscription tables, writes `config/initializers/upkeep.rb`,
150
+ mounts ActionCable when needed, pins Turbo and ActionCable for importmap apps,
151
+ and imports the browser bootstrap from `app/javascript/application.js`.
152
+
153
+ The browser bootstrap is vendored into the host app at
154
+ `app/javascript/upkeep/subscription.js` so it works with importmap and bundler
155
+ apps without a package-manager dependency. After upgrading `upkeep-rails`, rerun
156
+ the installer or compare that file with the generated template. A stale browser
157
+ client can subscribe with an old payload shape and be rejected by the channel.
158
+
159
+ ### 3. Render Normal Rails Views
160
+
161
+ No per-template annotations are required for ordinary Rails views. Controllers
162
+ keep loading Active Record models and relations:
163
+
164
+ ```ruby
165
+ class BoardsController < ApplicationController
166
+ def show
167
+ @board = Board.find(params[:id])
168
+ @cards = @board.cards.order(:position)
169
+ end
170
+ end
171
+ ```
172
+
173
+ Templates keep rendering ERB and partial collections:
174
+
175
+ ```erb
176
+ <main>
177
+ <h1><%= @board.name %></h1>
178
+
179
+ <ul id="cards">
180
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
181
+ </ul>
182
+ </main>
183
+ ```
184
+
185
+ Partials keep normal, stable HTML roots:
186
+
187
+ ```erb
188
+ <li id="<%= dom_id(card) %>">
189
+ <%= card.title %>
190
+ </li>
191
+ ```
192
+
193
+ At render time, Upkeep instruments Action View templates and adds the internal
194
+ `data-upkeep-*` markers it needs for page roots, fragment roots, and safe
195
+ collection render-site containers. A normal partial collection render like the
196
+ `<ul>` above can become a narrow render site when the collection render is the
197
+ container's only meaningful child. Successful HTML GET responses are captured
198
+ automatically and receive the subscription marker.
199
+
200
+ Upkeep also understands Rails' polymorphic collection render shorthand when the
201
+ runtime confirms that it rendered a collection:
202
+
203
+ ```erb
204
+ <ul id="cards">
205
+ <%= render @cards %>
206
+ </ul>
207
+ ```
208
+
209
+ For containers built with Rails tag helpers, Herb supplies the same structural
210
+ view of the tag that literal HTML would have. Upkeep can therefore keep this
211
+ idiom live without hand-written `data-upkeep-*` markers:
212
+
213
+ ```erb
214
+ <%= tag.ul id: "cards" do %>
215
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
216
+ <% end %>
217
+ ```
218
+
219
+ ### 4. Configure Subscription Storage
220
+
221
+ The generated initializer keeps production on the durable ActiveRecord store
222
+ and uses the in-process memory store for ordinary test runs:
223
+
224
+ ```ruby
225
+ # config/initializers/upkeep.rb
226
+ Upkeep::Rails.configure do |config|
227
+ app_config = Rails.application.config.upkeep
228
+
229
+ config.enabled = app_config.fetch(:enabled, true)
230
+ config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
231
+ end
232
+ ```
233
+
234
+ Use `config.upkeep.subscription_store = :active_record` in a test environment
235
+ or CI job when you want to exercise durable subscription rows, schema checks,
236
+ store reload, and cross-process lookup. Use the memory store for most
237
+ controller/system tests that only need the public subscription lifecycle.
238
+
239
+ ```ruby
240
+ # config/environments/test.rb
241
+ config.upkeep.subscription_store = :active_record
242
+ ```
243
+
244
+ For more than one Puma worker, configure ActionCable with a shared adapter such
245
+ as Redis or Solid Cable. The subscription store decides which subscribers need
246
+ work; ActionCable decides which worker owns each WebSocket connection.
247
+
248
+ The generated subscription source carries a stateless signed activation token,
249
+ and its default lifetime is 24 hours:
250
+
251
+ ```ruby
252
+ Upkeep::Rails.configure do |config|
253
+ config.activation_token_expires_in = 12.hours
254
+ end
255
+ ```
256
+
257
+ ### 5. Configure Identity For User-Specific Pages
258
+
259
+ Pages that depend on a user, account, tenant, or other authenticated actor need
260
+ an explicit identity bridge. The `current:`, `session:`, `cookie:`, or
261
+ `warden:` side tells Upkeep which render-time value is the identity. The
262
+ `subscribe` side tells Upkeep how to resolve the same identity from the
263
+ ActionCable connection:
264
+
265
+ ```ruby
266
+ # config/initializers/upkeep.rb
267
+ Upkeep::Rails.configure do |config|
268
+ config.identify :viewer, current: ["Current", :user] do
269
+ subscribe { |connection| connection.current_user }
270
+ end
271
+ end
272
+ ```
273
+
274
+ Read that as:
275
+
276
+ | Part | Meaning | How to choose it |
277
+ | --- | --- | --- |
278
+ | `:viewer` | The name of this identity component inside Upkeep. | Pick the role the value plays in authorization or personalization: `:viewer`, `:account`, `:tenant`, `:locale`, etc. |
279
+ | `current: ["Current", :user]` | The render-side source. This says: when a page reads `Current.user`, that value is the `:viewer` identity. | Use this when views, controllers, helpers, or presenters rely on an `ActiveSupport::CurrentAttributes` value. The first item is the Current class name, the second is the attribute. |
280
+ | `subscribe { |connection| connection.current_user }` | The ActionCable-side resolver. This says how the WebSocket connection proves the same `:viewer` value when it subscribes. | Return the same logical value as the render side, usually an Active Record record, GlobalID-capable object, string, number, symbol, boolean, array, or hash. |
281
+
282
+ The mental model is: the keyword argument describes what the HTML render read;
283
+ the `subscribe` block describes what the live WebSocket can prove. Upkeep only
284
+ authorizes the live subscription when those values match.
285
+
286
+ Choose the source keyword from the API your render path actually reads:
287
+
288
+ | App code reads | Declare | Subscribe side should return |
289
+ | --- | --- | --- |
290
+ | `Current.user` | `current: ["Current", :user]` | the same user, usually `connection.current_user` |
291
+ | Devise `current_user`, `user_signed_in?`, or raw `warden.user(:user)` | `warden: :user` | the same Devise user, usually `connection.current_user` |
292
+ | `session[:user_id]` | `session: :user_id` | the same session value, `connection.session[:user_id]` |
293
+ | `cookies[:account_id]` | `cookie: :account_id` | the same cookie value, `connection.cookies[:account_id]` |
294
+
295
+ If an app copies Devise's user into `Current.user` and the rendered page only
296
+ reads `Current.user`, use `current:`. If the same render also calls Devise's
297
+ `current_user` or `user_signed_in?`, declare the Warden source too or remove
298
+ the duplicate identity read from the rendered path.
299
+
300
+ The matching cable connection must expose that identity:
301
+
302
+ ```ruby
303
+ # app/channels/application_cable/connection.rb
304
+ module ApplicationCable
305
+ class Connection < ActionCable::Connection::Base
306
+ identified_by :current_user
307
+
308
+ def connect
309
+ self.current_user = User.find_by(id: request.session[:user_id])
310
+ end
311
+ end
312
+ end
313
+ ```
314
+
315
+ If the app uses Devise helpers in controllers or views, declare the Warden
316
+ scope Devise uses. The render side sees Devise through Warden; the cable side
317
+ can still return `connection.current_user`:
318
+
319
+ ```ruby
320
+ # config/initializers/upkeep.rb
321
+ Upkeep::Rails.configure do |config|
322
+ config.identify :viewer, warden: :user do
323
+ subscribe { |connection| connection.current_user }
324
+ end
325
+ end
326
+ ```
327
+
328
+ ```ruby
329
+ # app/channels/application_cable/connection.rb
330
+ module ApplicationCable
331
+ class Connection < ActionCable::Connection::Base
332
+ identified_by :current_user
333
+
334
+ def connect
335
+ self.current_user = env["warden"]&.user(:user)
336
+ end
337
+ end
338
+ end
339
+ ```
340
+
341
+ Use the matching Devise/Warden scope for other authenticated roles, such as
342
+ `warden: :admin`. If every cable subscription in the app requires login, the
343
+ connection can reject when `current_user` is nil; if the app also serves public
344
+ live pages, leave it nil so logged-out identity remains absent.
345
+
346
+ Session-backed identity can be declared directly:
347
+
348
+ ```ruby
349
+ Upkeep::Rails.configure do |config|
350
+ config.identify :viewer, session: :user_id do
351
+ subscribe { |connection| connection.session[:user_id] }
352
+ end
353
+ end
354
+ ```
355
+
356
+ Here `:viewer` still names the identity component, `session: :user_id` says the
357
+ render-side identity is `session[:user_id]`, and the `subscribe` block reads
358
+ the matching value from the ActionCable connection's session.
359
+
360
+ The `subscribe` block receives an Upkeep connection context. It delegates public
361
+ methods such as `current_user` to the ActionCable connection and exposes
362
+ `session` and `cookies` directly. The raw ActionCable `request` object is not
363
+ part of the public identity API.
364
+
365
+ By default, `nil` means a declared identity boundary is absent. That keeps
366
+ logged-out pages anonymous-public even when a layout checks `session[:user_id]`
367
+ or `Current.user`. If an app uses another sentinel for "not signed in", declare
368
+ it:
369
+
370
+ ```ruby
371
+ Upkeep::Rails.configure do |config|
372
+ config.identify :viewer, session: :user_id do
373
+ absent_if { |value| value.nil? || value == false }
374
+ subscribe { |connection| connection.session[:user_id] }
375
+ end
376
+ end
377
+ ```
378
+
379
+ If a page reads an undeclared non-absent `CurrentAttributes` or Warden identity,
380
+ Upkeep refuses live registration and reports `identity_setup_required` /
381
+ `unidentified_identity` rather than guessing.
382
+
383
+ ### 6. Configure Delivery
384
+
385
+ Upkeep dispatches committed Active Record changes through a delivery adapter.
386
+ Production Rails apps should use Active Job so planning, rendering, and
387
+ broadcasting do not run in the writer's request:
388
+
389
+ ```ruby
390
+ Upkeep::Rails.configure do |config|
391
+ config.delivery_adapter = Rails.env.production? ? :active_job : :async
392
+ config.delivery_queue = :upkeep_realtime
393
+ end
394
+ ```
395
+
396
+ This uses the app's normal Active Job backend. With Sidekiq, set
397
+ `config.active_job.queue_adapter = :sidekiq`. With Solid Queue, set
398
+ `config.active_job.queue_adapter = :solid_queue` and run the Solid Queue worker.
399
+ Upkeep does not talk to Sidekiq or Solid Queue directly.
400
+
401
+ The queue backend and the WebSocket broadcast backend are separate:
402
+
403
+ | Job backend | ActionCable backend | Redis required? |
404
+ | --- | --- | --- |
405
+ | Sidekiq | Redis | yes |
406
+ | Sidekiq | Solid Cable | only for Sidekiq |
407
+ | Solid Queue | Solid Cable | no |
408
+ | Solid Queue | Redis | only for ActionCable |
409
+ | Active Job async | ActionCable async | no, development/test only |
410
+
411
+ ActionCable still needs a shared adapter in multi-process deployments because
412
+ the job worker may not be the process holding the browser's WebSocket. Redis,
413
+ Solid Cable, and PostgreSQL are shared ActionCable adapters; `async` is only
414
+ for one-process development and tests.
415
+
416
+ For debugging, set `config.delivery_adapter = :inline` inside
417
+ `Upkeep::Rails.configure` to run delivery immediately. `:async` keeps the
418
+ previous process-local batching behavior and is useful for tests or small local
419
+ development.
420
+
421
+ ### 7. Keep Write Paths Focused
422
+
423
+ Writes keep doing domain work:
424
+
425
+ ```ruby
426
+ class CardsController < ApplicationController
427
+ def update
428
+ Card.find(params[:id]).update!(card_params)
429
+ head :ok
430
+ end
431
+ end
432
+ ```
433
+
434
+ After the commit, Upkeep dispatches invalidation facts to the configured
435
+ delivery adapter, rerenders the narrowest proven target, and sends Turbo Stream
436
+ payloads to connected browsers.
437
+
438
+ ## What Upkeep Observes
439
+
440
+ Render structure:
441
+
442
+ - Rails-resolved page templates.
443
+ - Partial and object partial renders.
444
+ - Action View-instrumented collection render sites and their child fragments.
445
+ - Polymorphic `render @records` collection shorthand when runtime rendering
446
+ confirms a collection.
447
+ - `tag.*` and `content_tag` containers lowered by Herb into ordinary template
448
+ structure.
449
+ - Single-root fragment targets and legal render-site container targets.
450
+
451
+ Template parsing:
452
+
453
+ - Upkeep plans narrow source-derived targets only from templates that pass
454
+ Herb's strict parser.
455
+ - If strict parsing fails but Herb can recover with `strict: false`, Upkeep
456
+ reports the strict parser diagnostics as warnings and may still add broad
457
+ page or fragment root markers.
458
+ - Recovered render sites are diagnostic only. Fix the strict warnings before
459
+ expecting narrow collection updates from that template.
460
+
461
+ Data dependencies:
462
+
463
+ - Active Record attribute reads.
464
+ - Active Record relation collection renders.
465
+ - Active Record callback writes and bulk `update_all` / `delete_all` writes.
466
+ - Relation table/column coverage derived from Arel where Rails exposes a
467
+ structural query shape.
468
+
469
+ Identity and ambient inputs:
470
+
471
+ - `ActiveSupport::CurrentAttributes` reads.
472
+ - Warden and Devise user reads through Warden.
473
+ - Session and cookie reads.
474
+ - Request values such as host, path, params, user agent, and remote IP.
475
+ - Declared Upkeep identities that map observed render-time values to
476
+ ActionCable subscribe-time values.
477
+
478
+ ## What Upkeep Cannot Capture
479
+
480
+ Upkeep captures reactive facts, not arbitrary Ruby execution. A boundary is
481
+ capturable only when Upkeep can prove the future write facts that affect it, the
482
+ target that can be replayed or patched, and the identity inputs that decide
483
+ whether it can be shared.
484
+
485
+ `Opaque` means Upkeep can see that application code used something, but Rails
486
+ did not expose enough structure for Upkeep to decide what future change should
487
+ refresh it or how to replay it safely.
488
+
489
+ This is a safety rule, not a parser preference. A live boundary must answer
490
+ three questions:
491
+
492
+ 1. Which future write facts can make this rendered result stale?
493
+ 2. Which target can Upkeep replay or patch when that happens?
494
+ 3. Which observed identity inputs decide whether the result can be shared?
495
+
496
+ If any answer is missing, Upkeep would have to choose between missing updates,
497
+ refreshing too broadly, or sharing viewer-specific output with the wrong
498
+ subscriber. It refuses that boundary instead.
499
+
500
+ | Surface | Why it is not capturable | Developer experience |
501
+ | --- | --- | --- |
502
+ | Opaque Active Record relations: raw SQL predicates, raw joins, raw `from` sources, unknown table aliases, opaque order expressions, or opaque pluck columns. | Rails no longer exposes enough structure to prove table, column, predicate, and lifecycle coverage. | Development/test raises `Upkeep::ActiveRecordQuery::OpaqueRelationError` before registering. Warn mode logs, emits `refused_boundary.upkeep`, and refuses the live boundary instead of widening to an unsafe dependency. Rewrite with structural Active Record or Arel when the boundary should be live. |
503
+ | Controller queries that are never rendered as a collection boundary. | There is no DOM collection surface where membership can be appended, removed, prepended, or replaced. | The page can still render normally. Scalar relation output can be tracked as a page-level dependency, but it does not unlock collection stream planning. Render the relation through a collection partial when collection lifecycle matters. |
504
+ | Reads from external stores or process state: Redis, HTTP APIs, files, global variables, class variables, singleton caches, background thread state, or service memoization. | Active Record commit facts cannot select these reads, and Upkeep has no source adapter for their lifecycle. | They are not live dependencies today. If another observed dependency causes a replay, normal Rails code may read the new value during that replay; the external read itself will not trigger one. Use existing app mechanisms, explicit broadcasts, or future source adapters for those domains. |
505
+ | Writes outside observed Active Record paths: direct connection SQL, writes in another datastore, or side effects that do not emit Upkeep change facts. | Upkeep cannot match a future change to an existing surface without a write fact. | No refresh is scheduled from that write. Prefer Active Record write APIs for capturable models, or keep a manual invalidation/broadcast path for sources Upkeep does not observe yet. |
506
+ | Replay inputs that cannot be rebuilt: arbitrary objects, procs, IO handles, open clients, or values that only exist in one Ruby process. | A captured target must be replayable later, often in a different request context. | Keep frame locals and render options to records, relations, arrays, hashes, literals, and observed request/session/cookie values. Non-replayable values block the narrow replay path until they are represented as stable data. |
507
+ | Patch targets Upkeep cannot identify in rendered HTML. | Delivery needs a stable page, render-site, fragment, or member target. Upkeep adds those markers for ordinary page templates, partial roots, and safe collection render sites, but opaque generated markup can still hide a target. | Upkeep uses the narrowest proven target. If a narrow target is not proven but an enclosing target is, delivery deoptimizes to the enclosing target; if no safe target exists, the boundary is refused or tests expose the missing target. |
508
+
509
+ ## Query Shapes
510
+
511
+ Collection dependencies are accepted only with proven column coverage. Opaque
512
+ predicates or table-only sources are refused instead of widening into broad
513
+ invalidation.
514
+
515
+ Controller materialization is supported when the rendered value keeps a
516
+ structural relation proof:
517
+
518
+ ```ruby
519
+ def index
520
+ @cards = Card.where(status: "open").order(:position).to_a
521
+ end
522
+ ```
523
+
524
+ ```erb
525
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
526
+ ```
527
+
528
+ Upkeep attaches the collection dependency to the rendered collection boundary,
529
+ not to every controller query. A materialized relation that is never rendered as
530
+ a collection is not a lifecycle dependency by itself.
531
+
532
+ Scalar relation output is tracked as a page-level query dependency:
533
+
534
+ ```ruby
535
+ @tag_names = Tag.where(active: true).pluck(:name)
536
+ ```
537
+
538
+ Simple plucked columns are live and can select a page replay when they change.
539
+ They are not collection dependencies, so they do not participate in
540
+ append/remove/prepend planning.
541
+
542
+ ## Refused Boundaries
543
+
544
+ Upkeep distinguishes a refused boundary from a deoptimization.
545
+
546
+ A **refused boundary** means Upkeep cannot prove correctness. In
547
+ development/test, refused boundaries raise by default. In production, they warn,
548
+ emit `refused_boundary.upkeep`, and skip live registration for that boundary by
549
+ default:
550
+
551
+ ```ruby
552
+ Upkeep::Rails.configure do |config|
553
+ config.refused_boundary_behavior = :raise # or :warn
554
+ end
555
+ ```
556
+
557
+ This is intentional. A page that cannot be proven should behave like ordinary
558
+ Rails HTML instead of registering a broad or unsafe live dependency.
559
+
560
+ A **deoptimization** means Upkeep can still prove correctness, but not the
561
+ cheapest operation. The page remains live, and delivery falls back to a broader
562
+ proven target such as a render site or page replay. Planning and delivery
563
+ telemetry record the deoptimization reason so benchmarks can separate safety
564
+ fallbacks from true refusal.
565
+
566
+ ## Testing
567
+
568
+ Use `Upkeep::Rails::Testing` for app-level assertions around subscription
569
+ registration and delivery. See [Testing](docs/testing.md) for the local green
570
+ bar, benchmark apps, proof runner, and integration helpers.
571
+
572
+ Structure app tests around behavior, not store internals:
573
+
574
+ - Most request/system tests can run with `config.upkeep.subscription_store =
575
+ :memory`. Memory has the same public lifecycle as ActiveRecord: registration
576
+ is fetchable immediately, lookup visibility starts on activation, touch
577
+ updates liveness, unregister/prune remove lookup entries, and delivery uses
578
+ the same planner surface.
579
+ - Keep a smaller ActiveRecord-backed integration slice for the production-only
580
+ concerns: generated migration shape, schema validation, durable rows,
581
+ reload/rehydration, async persistence, and cross-process lookup.
582
+ - Do not assert implementation details that are unique to one store unless the
583
+ test is explicitly about that implementation. For app behavior, assert the
584
+ marker, activation, streams, broadcasts, and rendered bytes.
585
+
586
+ Run the project test suite with the project Ruby:
587
+
588
+ ```sh
589
+ mise exec -- ruby -S rake test
590
+ mise exec -- ruby bin/test
591
+ ```
592
+
593
+ `bin/test` runs the gem tests, both maintained benchmark app test suites, and
594
+ the proof runner. The proof runner writes JSON reports to `results/`.
595
+
596
+ ## Further Reading
597
+
598
+ - [Getting Started](docs/guides/getting-started.md): add the package to a Rails
599
+ app, create subscription storage, configure ActionCable, and verify
600
+ subscription registration.
601
+ - [Identity And Sharing](docs/architecture/identity-and-sharing.md): how
602
+ declared subscriber identities and observed ambient inputs partition
603
+ delivery.
604
+ - [Ambient Inputs Roadmap](docs/architecture/ambient-inputs-roadmap.md): how
605
+ controller, session, cookie, request, and before-action state should support
606
+ real Rails apps without whole-session replay or ambient-input allow-lists.
607
+ - [Query Dependencies](docs/architecture/query-dependencies.md): how Active
608
+ Record/Arel relation shape drives collection invalidation without a host
609
+ query catalog.
610
+ - [Production Roadmap](docs/production_roadmap.md): hardening work, benchmark
611
+ coverage, and compatibility tracking beyond the current release contract.
612
+ - [Cost Model Roadmap](docs/cost-model-roadmap.md): runtime cost boundaries,
613
+ Turbo baseline positioning, Herb-backed template structure, and the
614
+ large-scale direction for proven incremental rendering.