upkeep-rails 0.2.5-arm64-darwin

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.dylib +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 66c260f39c1b3fcb6600bf856ca34a2fa06e69bef14a97d98b03c929a4e96a53
4
+ data.tar.gz: 60d3ecaf3f8fe9d2875944d7b3fc8af05f165ab851a55377457d8319dad03d6f
5
+ SHA512:
6
+ metadata.gz: 76cef154c7394c13dcf4e9cd5ee60be3132c1077767da59a44a9bc6865381555d3fcbc45c2bd1b4f01d91cb28e584427317b4b0d7360ba4a58b601e7979fcdba
7
+ data.tar.gz: 22560f3829fa126b9ad3503404a9f14c5ffbca48ca6e44b965736bfd49426ba1b3ea398aadf52de85483f7e8906b2ca380d1bbcbac8338e9df542ba1f38bc329
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,244 @@
1
+ # Upkeep Rails
2
+
3
+ Upkeep automatically syncs record changes to browsers showing affected data. When you save a record, it figures out which rendered pages depend on it and sends Turbo Streams to the right subscribers.
4
+
5
+ Instead of naming stream targets in your controller:
6
+
7
+ ```ruby
8
+ class CardsController < ApplicationController
9
+ def create
10
+ @card = Card.create!(card_params)
11
+ respond_to { |f| f.turbo_stream }
12
+ end
13
+ end
14
+ ```
15
+
16
+ ```erb
17
+ <%= turbo_stream.append "cards", partial: "card", locals: { card: @card } %>
18
+ ```
19
+
20
+ You just save the record:
21
+
22
+ ```ruby
23
+ class CardsController < ApplicationController
24
+ def create
25
+ Card.create!(card_params)
26
+ head :no_content
27
+ end
28
+ end
29
+ ```
30
+
31
+ Upkeep observed which records the initial GET read, so it knows the page depends on the card table. When the create commits, it broadcasts to the right browsers automatically.
32
+
33
+ ## Install
34
+
35
+ Add the gem to your Gemfile:
36
+
37
+ ```ruby
38
+ gem "upkeep-rails"
39
+ ```
40
+
41
+ Run the installer:
42
+
43
+ ```sh
44
+ bin/rails generate upkeep:install
45
+ bin/rails db:migrate
46
+ ```
47
+
48
+ This creates the subscription tables, writes an initializer, mounts ActionCable if needed, and imports the browser client.
49
+
50
+ Requirements: Ruby 3.2+, Rails 7.1+, `turbo-rails` 2.x, and Turbo JavaScript 8.x.
51
+
52
+ Releases are precompiled for `x86_64-linux-gnu`, `aarch64-linux-gnu`,
53
+ `x86_64-darwin`, `arm64-darwin`, and `x64-mingw-ucrt`. Installing Upkeep does
54
+ not require Rust.
55
+ Upkeep 0.2 requires a full bundle reinstall so Bundler selects the matching
56
+ Upkeep platform gem; mixed installs with 0.1 are not supported.
57
+
58
+ ## Configure runtime
59
+
60
+ The installer creates `config/initializers/upkeep.rb`. Most projects work with the defaults, but you can customize:
61
+
62
+ ```ruby
63
+ Upkeep::Rails.configure do |config|
64
+ config.subscription_store = :active_record # :memory for tests
65
+ config.deliver_inline = false # true for tests/console
66
+ config.refused_boundary_behavior = :warn # :raise in strict mode
67
+ end
68
+ ```
69
+
70
+ To introduce Upkeep one controller action at a time, use opt-in request activation:
71
+
72
+ ```ruby
73
+ Upkeep::Rails.configure do |config|
74
+ config.request_activation = :opt_in
75
+ end
76
+
77
+ class Projects::MilestonesController < ApplicationController
78
+ upkeep_reactive only: :index
79
+ end
80
+ ```
81
+
82
+ This changes only which GET and HEAD responses register live subscriptions.
83
+ Writes from every controller remain captured so they can update subscribed
84
+ pages. Routes and Turbo navigation do not change.
85
+
86
+ Queries performed by controller actions or callbacks are page-level
87
+ dependencies unless rendering establishes a narrower boundary. For
88
+ presentation-specific controller work, see
89
+ [Controller Work and Render Regions](docs/how-it-works.md#controller-work-and-render-regions).
90
+
91
+ **Subscription store:** Use `:memory` for test suites. Use `:active_record` for production—it stores subscriptions durably across restarts.
92
+
93
+ **Delivery mode:** Upkeep broadcasts from a background dispatcher in the same process that performed the write. For tests and console sessions where you want synchronous behavior, set `deliver_inline = true`.
94
+
95
+ **Background jobs:** Active Job executions are captured automatically, including jobs run through Sidekiq's Active Job adapter. A job that commits an Active Record change therefore updates subscribed pages just like a controller write. Direct Sidekiq workers that bypass Active Job are not captured.
96
+
97
+ **Subscription lifecycle:** Subscriptions clean themselves up. A clean disconnect deletes the subscription immediately; abandoned subscriptions (crashed browsers, dropped connections) are trimmed opportunistically in small batches once they go untouched for `config.subscription_ttl` (default 24 hours); and connected pages send a liveness heartbeat every 20 minutes, so a long-lived open tab is never pruned. Adjust `subscription_ttl` to control how long abandoned subscriptions may linger.
98
+
99
+ **Multi-process deployments:** If you run multiple Puma workers, you need a cross-process cable adapter so broadcasts reach all processes. We recommend `solid_cable`.
100
+
101
+ ## Configure identity
102
+
103
+ If your pages depend on the current user, account, or any viewer-specific value, tell Upkeep how to match that between render time and subscription time.
104
+
105
+ First, declare the identity in the initializer:
106
+
107
+ ```ruby
108
+ Upkeep::Rails.configure do |config|
109
+ config.identify :viewer, current: ["Current", :user] do
110
+ subscribe { |connection| connection.current_user }
111
+ end
112
+ end
113
+ ```
114
+
115
+ Then make sure your cable connection exposes it:
116
+
117
+ ```ruby
118
+ module ApplicationCable
119
+ class Connection < ActionCable::Connection::Base
120
+ identified_by :current_user
121
+
122
+ def connect
123
+ self.current_user = User.find_by(id: request.session[:user_id])
124
+ end
125
+ end
126
+ end
127
+ ```
128
+
129
+ Pick the declaration that matches how your render path reads the identity:
130
+
131
+ | Render path | Configuration | Subscribe returns |
132
+ | --- | --- | --- |
133
+ | `Current.user` | `current: ["Current", :user]` | `connection.current_user` |
134
+ | Devise/Warden | `warden: :user` | `connection.current_user` |
135
+ | `session[:user_id]` | `session: :user_id` | `connection.session[:user_id]` |
136
+ | `cookies[:account_id]` | `cookie: :account_id` | `connection.cookies[:account_id]` |
137
+
138
+ For logged-out pages where `nil` is a valid identity, declare how to recognize "absent":
139
+
140
+ ```ruby
141
+ config.identify :viewer, session: :user_id do
142
+ absent_if { |value| value.nil? || value == false }
143
+ subscribe { |connection| connection.session[:user_id] }
144
+ end
145
+ ```
146
+
147
+ If your render path reads an undeclared identity value (like a `CurrentAttributes` field), Upkeep will refuse to register that page for live updates rather than guess who should receive the broadcast.
148
+
149
+ ## When Upkeep can't track your data
150
+
151
+ Upkeep parses the SQL generated by Active Record, so ordinary raw predicates,
152
+ joins, aliases, subqueries, and CTEs do not need to be rewritten as Arel:
153
+
154
+ ```ruby
155
+ Story.where("score >= 0")
156
+ Story.order("created_at DESC")
157
+ User.joins("INNER JOIN posts ON ...")
158
+ Story.from("(SELECT * FROM stories WHERE ...)")
159
+ ```
160
+
161
+ If a query cannot be parsed or resolved safely against the database schema,
162
+ Upkeep refuses to register that page rather than subscribe to an incomplete
163
+ set of tables. By default, it raises in development and test, and logs a
164
+ warning in production while still rendering the page. You can change this
165
+ behavior:
166
+
167
+ ```ruby
168
+ Upkeep::Rails.configure do |config|
169
+ config.refused_boundary_behavior = :warn # or :raise
170
+ end
171
+ ```
172
+
173
+ For queries that use unsupported database-specific syntax, opt the request out
174
+ entirely:
175
+
176
+ ```ruby
177
+ class StoriesController < ApplicationController
178
+ def index
179
+ @stories = params[:query].present? ? Story.search(params[:query]) : Story.all
180
+ end
181
+
182
+ private
183
+
184
+ def upkeep_reactive_request?
185
+ return false if params[:query].present?
186
+ super
187
+ end
188
+ end
189
+ ```
190
+
191
+ The request still renders normally. Upkeep just doesn't register subscriptions or analyze the boundary. The unfiltered view (without a query param) stays reactive.
192
+
193
+ **General rule:** if SQLGlot can resolve the generated SQL against the
194
+ application schema, Upkeep can track it. Otherwise Upkeep renders
195
+ conservatively and does not register a potentially incomplete subscription.
196
+
197
+ ## How Upkeep compares to Turbo Streams
198
+
199
+ With vanilla Turbo Streams, the write action names which parts of the UI to refresh. You maintain the list of targets as your UI evolves:
200
+
201
+ ```ruby
202
+ class CardsController < ApplicationController
203
+ def create
204
+ @board = Board.find(params[:board_id])
205
+ @card = @board.cards.create!(card_params)
206
+
207
+ respond_to do |format|
208
+ format.turbo_stream
209
+ end
210
+ end
211
+ end
212
+ ```
213
+
214
+ ```erb
215
+ <%= turbo_stream.append "cards", partial: "card", locals: { card: @card } %>
216
+ <%= turbo_stream.update "board_open_count", @board.open_count %>
217
+ ```
218
+
219
+ This couples the write path to the current UI. When you add a sidebar showing the same board, or a filter that depends on card status, you revisit the controller and stream template.
220
+
221
+ With Upkeep, the write path just saves the record. The subscription comes from the read:
222
+
223
+ ```ruby
224
+ class CardsController < ApplicationController
225
+ def create
226
+ Board.find(params[:board_id]).cards.create!(card_params)
227
+ head :no_content
228
+ end
229
+ end
230
+ ```
231
+
232
+ Upkeep observed which records your initial GET read, so it knows which renders care about this card. It broadcasts to those subscribers automatically. You add a sidebar or filter without touching the write path.
233
+
234
+ The tradeoff: Turbo Streams let you control exactly what gets broadcast and how it's rendered (you might patch a counter without rerendering the list). Upkeep derives updates from renders it observed, so it's narrower but also more opinionated about safety.
235
+
236
+ | Concern | Turbo Streams | Upkeep |
237
+ | --- | --- | --- |
238
+ | Write path | Declares targets and templates | Just commits the record |
239
+ | Boundary discovery | You maintain it | Upkeep infers from renders |
240
+ | Unsafe broadcasts | You decide how broad | Upkeep refuses unsafe patterns |
241
+ | Render changes | Update the stream template | Happens automatically |
242
+ | Full control | Yes | No, but less coupling |
243
+
244
+ See [How Upkeep Works](docs/how-it-works.md) for details on the runtime model.
@@ -0,0 +1,240 @@
1
+ # Live Rails pages without writing broadcasts
2
+
3
+ ---
4
+
5
+ A user posts a comment. Easy: `@comment.save`, redirect, done. Then someone asks for it to show up live, and the Turbo docs have you covered: add `broadcasts_to :post` and you're on the air. Then the comment count in the sidebar goes stale, so you add a broadcast for that. Then the moderation queue needs one. Then the author's profile page, which shows their latest activity. Six months later the `Comment` model broadcasts to four streams, and there's a comment in the code that says `# don't remove, the dashboard breaks`.
6
+
7
+ If any of this sounds familiar, you're in good company. Nobody plans this; every one of those broadcasts was the reasonable next step on the day it was written. In this post we'll look at why this pattern decays no matter how careful you are, and we'll introduce [Upkeep](https://github.com/fc-anjos/upkeep-rails), an open source gem we built that takes a different approach: instead of you telling Rails which pages a write affects, it watches your pages render and works that out on its own.
8
+
9
+ ## The list nobody checks
10
+
11
+ Here's the standard Turbo Streams shape, straight from any tutorial. The write action names the parts of the UI it affects:
12
+
13
+ ```ruby
14
+ class CardsController < ApplicationController
15
+ def create
16
+ @board = Board.find(params[:board_id])
17
+ @card = @board.cards.create!(card_params)
18
+
19
+ respond_to do |format|
20
+ format.turbo_stream
21
+ end
22
+ end
23
+ end
24
+ ```
25
+
26
+ ```erb
27
+ <%= turbo_stream.append "cards", partial: "card", locals: { card: @card } %>
28
+ <%= turbo_stream.update "board_open_count", @board.open_count %>
29
+ ```
30
+
31
+ This works, and it ships. Now let's look closer at what those stream lines really are. Each one is a rule, and the rule says: when a card changes, this part of this page needs updating. You wrote that rule by hand, and here's the trouble: the app will happily let it go stale. Suppose a teammate adds an open-card count to the dashboard. It renders correctly, it ships, and it's out of date within the hour, because nobody knew the dashboard now needed a broadcast too. No test fails, since stale pages don't raise. Sooner or later a bug report arrives that says "sometimes the dashboard is behind," and those reports keep coming, because nothing in the app checks that the broadcast list still matches the UI.
32
+
33
+ The decay runs the other way too. Someone redesigns the board page and removes the open count, and the `turbo_stream.update "board_open_count"` line stays behind, broadcasting into an element that no longer exists. Rails will happily verify your routes, your migrations, and even your N+1 queries if you install a gem for it. This list, though, has no verifier at all.
34
+
35
+ If the pattern sounds familiar beyond Turbo, it should: this is cache invalidation. A hand-maintained set of "when X changes, Y is stale" rules is exactly the thing we all agreed was one of the two hard problems, and fragment caching moved away from it years ago with key-based expiration, precisely because hand-written invalidation doesn't survive contact with a changing codebase. Live updates are the same problem in a party hat.
36
+
37
+ There's also a second, trickier version of this problem: a broadcast doesn't just need the right target, it needs the right audience. If a partial renders differently for an admin than for a visitor, sending one viewer's HTML to everyone on the stream is a leak, and hand-written broadcasting leaves that entirely up to you. Keep this one in mind, because it comes back later. For now, let's stay with staleness, and with the question we kept coming back to while building live features: what if these rules could be derived from the pages themselves, instead of written by hand?
38
+
39
+ ## Deriving the rules from renders
40
+
41
+ That's what Upkeep does. When a page renders during an ordinary GET request, Upkeep watches: which records were read, which columns, what query produced that list of cards. It stores that as a subscription for the page. Later, when a write commits, it checks the write against the stored subscriptions and sends Turbo Streams to exactly the browsers whose pages went stale. The create action is left with one job:
42
+
43
+ ```ruby
44
+ class CardsController < ApplicationController
45
+ def create
46
+ Board.find(params[:board_id]).cards.create!(card_params)
47
+ head :no_content
48
+ end
49
+ end
50
+ ```
51
+
52
+ The stream template is gone, and so is the `broadcasts_to` line in the model. Upkeep already knows the targets, because it saw them render: it plans an `append` into the card list it observed and an `update` for the count it observed, and it delivers them to the subscribed browsers when the create commits.
53
+
54
+ And when your teammate adds that open-card count to the dashboard next month? The dashboard's next render records the new dependency, and the page is live from its first request. Nobody had to remember anything, and that's the whole idea: the dependency list is rebuilt from real renders, so it can't drift out of date.
55
+
56
+ Getting started is the usual two steps:
57
+
58
+ ```ruby
59
+ gem "upkeep-rails"
60
+ ```
61
+
62
+ ```sh
63
+ bin/rails generate upkeep:install
64
+ bin/rails db:migrate
65
+ ```
66
+
67
+ The installer creates the subscription tables, writes an initializer, and imports the browser client. Requirements are Ruby 3.2+, Rails 7.1+, `turbo-rails` 2.x, and Turbo JavaScript 8.x.
68
+
69
+ ### Declaring identities: the one new concept
70
+
71
+ If your pages depend on the current user, there's one more step, and it's fair to present it as the one genuinely new concept Upkeep asks you to learn. With hand-written broadcasts, Rails never asks which session or `Current` values shaped a page, because you're the one deciding who gets each broadcast. Upkeep makes that decision for you, and it won't make it on a guess. So the mapping between what the page read at render time and what the ActionCable connection can prove at subscribe time has to come from you.
72
+
73
+ Let's set it up for a Devise app. Devise authenticates through Warden, so we declare a `:viewer` identity backed by the Warden user:
74
+
75
+ ```ruby
76
+ # config/initializers/upkeep.rb
77
+ Upkeep::Rails.configure do |config|
78
+ config.identify :viewer, warden: :user do
79
+ subscribe { |connection| connection.current_user }
80
+ end
81
+ end
82
+ ```
83
+
84
+ Reading it as a sentence: "when a render reads the Warden `:user`, deliver its updates only to subscribers whose connection presents the same `current_user`." The `subscribe` block is evaluated against your ActionCable connection, so the connection needs to expose that value, which is the standard Devise-and-ActionCable setup you may already have:
85
+
86
+ ```ruby
87
+ # app/channels/application_cable/connection.rb
88
+ module ApplicationCable
89
+ class Connection < ActionCable::Connection::Base
90
+ identified_by :current_user
91
+
92
+ def connect
93
+ self.current_user = env["warden"].user
94
+ end
95
+ end
96
+ end
97
+ ```
98
+
99
+ One more wrinkle worth handling on day one: logged-out pages. When nobody is signed in, the Warden read comes back `nil`, and Upkeep needs to know whether `nil` means "a viewer we couldn't identify" (don't share!) or "anonymous public" (share freely). You tell it with `absent_if`:
100
+
101
+ ```ruby
102
+ config.identify :viewer, warden: :user do
103
+ absent_if { |value| value.nil? }
104
+ subscribe { |connection| connection.current_user }
105
+ end
106
+ ```
107
+
108
+ With that in place, your public pages get the cheap shared broadcasting from the previous section, and your signed-in pages get updates scoped to the right viewer. If a page reads an identity you haven't declared, Upkeep refuses to make that page live and names the missing declaration, so a forgotten mapping shows up as a development-time message rather than as someone else's HTML.
109
+
110
+ For a typical Devise app, this is a few lines written once. If your app derives viewer state in several creative ways, budget a real hour for it, and let the refusal messages point you to each source that needs declaring. The README covers the other three shapes: `Current` attributes, session keys, and cookies.
111
+
112
+ ### Where does all this live?
113
+
114
+ A reasonable question at this point: Upkeep is remembering what every open page depends on, so where does that memory go, and what keeps it from growing forever?
115
+
116
+ The installer's migration creates three tables. `upkeep_subscriptions` holds one row per subscribed page, with the page's dependency graph serialized as JSON. `upkeep_subscription_index_entries` is the reverse index that write matching runs against: table names, columns, and predicate digests, laid out so a committed write can find its candidate subscriptions with an indexed lookup instead of scanning graphs. And `upkeep_subscription_shape_index_entries` deduplicates that index across identical pages, so ten thousand people looking at the same public feed share index rows instead of multiplying them. The subscription row is written when the page renders, and the index rows are written when the browser's ActionCable connection comes up, which means half-loaded pages that never connect don't clutter the index.
117
+
118
+ Cleanup follows the same pattern Solid Cache and Solid Cable use for their tables: it rides on the gem's own traffic, with no scheduler for you to remember. Three rules keep the tables bounded. When a browser disconnects cleanly, its subscription row is deleted on the spot. While a browser stays connected, its channel touches the subscription every twenty minutes, so a dashboard left open for a week is never mistaken for garbage. And everything that hasn't been touched within the TTL, twenty-four hours by default via `config.subscription_ttl`, gets collected opportunistically: every hundredth registration prunes one bounded batch of expired rows, wrapped so a cleanup hiccup can never fail a request. Crashed tabs and dropped connections leave rows behind, and the next day's traffic quietly sweeps them out.
119
+
120
+ Deploys get a tidy answer from machinery you've already seen. Subscription shapes carry the gem version in their digest, and shared stream names carry a digest of the render recipe, so a subscription stored under last week's templates simply stops matching anything the new code broadcasts. It can't replay an old template or deliver against a renamed target; the worst it can do is nothing, until the TTL collects it. Refuse rather than guess, applied to time.
121
+
122
+ ## Wait, doesn't automatic invalidation always guess wrong?
123
+
124
+ Fair question! Automatic invalidation has a deserved reputation: systems that guess which pages depend on which data tend to guess wide, refresh too much, and eventually get turned off. So let's spend a minute on why Upkeep can afford to be precise.
125
+
126
+ The trick is that Active Record queries are, most of the time, structured data. Rails builds them as Arel, and Arel can be read like a description: which table, which columns, which predicates, which ordering. Take a page that renders this:
127
+
128
+ ```ruby
129
+ @cards = Card.where(status: "open").order(:position)
130
+ ```
131
+
132
+ Upkeep reads the structure behind that relation and stores what the query means. An insert affects this page only if the new row's status is open. An update matters only if it touched `status` or `position`, or belongs to a row that's already rendered. Any other write to the cards table can churn all day without producing a single broadcast, because the stored structure proves the page doesn't care.
133
+
134
+ Let's peek under the hood, because this part is less magical than it sounds. At render time, Upkeep walks the Arel tree of every relation the page reads. The walker is a plain old `case` statement over node types (here it is from the gem's source, trimmed down):
135
+
136
+ ```ruby
137
+ # lib/upkeep/active_record_query.rb
138
+ def walk(value, source: false)
139
+ case value
140
+ when Arel::Attributes::Attribute
141
+ attribute(value)
142
+ when Arel::Nodes::Equality
143
+ walk(value.left, source: source)
144
+ walk(value.right, source: source) if value.right.is_a?(Arel::Attributes::Attribute)
145
+ when Arel::Nodes::HomogeneousIn
146
+ walk(value.attribute, source: source)
147
+ when Arel::Table
148
+ table(value.name)
149
+ # ... more structural node types ...
150
+ when Arel::Nodes::StringJoin
151
+ opaque_table!("raw SQL join")
152
+ when Arel::Nodes::BoundSqlLiteral, Arel::Nodes::SqlLiteral
153
+ source ? opaque_table!("raw SQL source") : opaque_column!("raw SQL predicate or order expression")
154
+ end
155
+ end
156
+ ```
157
+
158
+ Both halves of the story are visible in one screen. Structural nodes like `Equality` and `Table` contribute columns and tables to the page's dependency record, while a `SqlLiteral` or a string join lands in an `opaque_table!` branch, and that's the refusal we'll get to in a moment.
159
+
160
+ The other half of the mechanism runs at write time. Every stored collection dependency knows how to answer one question about a committed change, and the method that answers it fits on a napkin:
161
+
162
+ ```ruby
163
+ # lib/upkeep/dependencies.rb
164
+ def matches_change?(change)
165
+ return false unless table_columns.key?(change.fetch(:table))
166
+
167
+ predicate_match = predicate_match(change)
168
+ return predicate_match unless predicate_match == UNKNOWN
169
+
170
+ return true if create_change?(change)
171
+ return true if delete_change?(change)
172
+
173
+ table_columns.fetch(change.fetch(:table)).intersect?(change.fetch(:changed_attributes, []))
174
+ end
175
+ ```
176
+
177
+ Reading it top to bottom: a write to a different table never matches; if the stored predicate can decide (the new row's `status` is `"open"`, or it isn't), its answer wins; when the predicate can't decide, creates and deletes match because they can change membership, and updates match only if the changed columns overlap the ones this page depends on. That last line is the "churn all day" guarantee from a moment ago, as one `intersect?` call. When a write does match, Upkeep picks the narrowest update it can justify: an `append` for a new member, a `replace` for a changed one, and when the narrow proof isn't available, a broader but still proven re-render of the enclosing container.
178
+
179
+ Now for the important part: what happens when the query isn't structured data?
180
+
181
+ ```ruby
182
+ Story.where("score >= 0")
183
+ User.joins("INNER JOIN posts ON ...")
184
+ ```
185
+
186
+ Rails hands these to the database as opaque strings, so there's no structure left for Upkeep to read. Here Upkeep makes the call that shapes the whole tool: it declines to track that page. It raises in development so you find out right away, warns in production, and the page renders as ordinary, perfectly functional, non-live HTML. Often the fix is a one-line rewrite into something structural, like `Story.where(Story.arel_table[:score].gteq(0))`. Sometimes there is no structural rewrite, with full-text search against a raw `tsvector` being the classic case, and for those you opt the request out and keep the rest of the page reactive.
187
+
188
+ We'd rather give you a page that's honestly static than one that's confidently wrong. A hand-written broadcast that's out of date fails silently in production, and we've all seen how vague those bug reports get. A derived rule that can't be proven fails loudly, at development time, with a message that tells you which query to fix.
189
+
190
+ ## The bonus we didn't expect: fan-out gets cheap
191
+
192
+ We built the proof machinery for correctness, and then it handed us a performance feature.
193
+
194
+ Think about the pages where live updates matter most: feeds, boards, leaderboards, dashboards. Lots of people staring at the same data. With hand-written broadcasting, the safe general pattern is to do the rendering work per stream, because nothing in the app can tell whether two subscribers are seeing identical HTML. The fan-out cost grows with your audience.
195
+
196
+ Upkeep can tell. Identity reads, meaning `Current.user`, Warden, session, and cookies, are part of what it records at render time, and so is their absence. A page that read no viewer-specific data is provably public, and "provably" is doing real work in that sentence. Here's the method that decides, straight from the gem:
197
+
198
+ ```ruby
199
+ # lib/upkeep/shared_streams.rb
200
+ def identity_signature_for(graph, frame_id)
201
+ identity_dependencies = graph.contained_node_ids(frame_id)
202
+ .flat_map { |owner_id| graph.dependencies_for(owner_id) }
203
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
204
+ .uniq(&:cache_key)
205
+ return "public" if identity_dependencies.empty?
206
+
207
+ Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
208
+ end
209
+ ```
210
+
211
+ It gathers every identity read recorded under a frame during rendering. If the list is empty, the frame is public, and public frames with the same render recipe hash to the same shared stream name. A provably public page has a pleasant property: every subscriber is seeing the same bytes. So when the data changes, Upkeep renders the update once and broadcasts it once, whether twelve browsers are watching or ten thousand.
212
+
213
+ ## What Upkeep refuses to do
214
+
215
+ A tool that promises to refuse rather than guess owes you the list of what it refuses. Here it is.
216
+
217
+ | It can't track | Because | So |
218
+ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
219
+ | Opaque relations: raw SQL predicates, raw joins, raw `from`, opaque order expressions | Rails no longer exposes enough structure to prove table, column, and predicate coverage | The boundary is refused and the page renders as ordinary HTML |
220
+ | Reads from Redis, HTTP APIs, files, globals, memoized service state | Active Record commit facts can't select these reads | They aren't live dependencies; a replay triggered by something else will pick up their current value |
221
+ | Writes that bypass Active Record: direct connection SQL, other datastores | Without a write fact there's nothing to match against stored subscriptions | No refresh is scheduled from that write |
222
+ | Viewer-specific pages whose identity you haven't declared | Upkeep won't infer who should receive HTML from naming conventions | Live registration is refused rather than risking the wrong browser |
223
+ | Templates that fail Herb's strict ERB parse | Narrow update targets are derived from template structure | You get broad page-level updates and a diagnostic instead of surgical ones |
224
+
225
+ Every row is the same decision applied to a different surface: where correctness can't be proven, the page behaves like the plain Rails HTML it always was. One distinction worth knowing before you read your logs: a _deoptimization_ means Upkeep found a broader target it can still prove, so the page stays live with a coarser update, while a _refusal_ means the page isn't live at all. The diagnostics tell you which one you got, and why.
226
+
227
+ ## Enjoy deleting that comment
228
+
229
+ This post started with a `# don't remove, the dashboard breaks` comment guarding four hand-written broadcasts. With subscriptions derived from renders, that comment has nothing left to protect. Install the gem, take the broadcasts out, save a record, and watch the right pages update on their own.
230
+
231
+ ```sh
232
+ bin/rails generate upkeep:install
233
+ bin/rails db:migrate
234
+ ```
235
+
236
+ And the audience problem from earlier? Upkeep records every identity read a page makes, including `Current.user`, Warden, session, and cookies, and only delivers an update to subscribers who can prove the same identity over ActionCable. If a page depends on an identity you haven't declared, it refuses to make that page live rather than send viewer-specific HTML to the wrong browser.
237
+
238
+ ---
239
+
240
+ _Upkeep is MIT-licensed and lives at [github.com/TODO-repo-link](https://github.com/TODO-repo-link). Issues, questions, and opaque-query war stories are all welcome._