upkeep-rails 0.1.9
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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +311 -0
- data/docs/how-it-works.md +269 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +392 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +550 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +518 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +920 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +154 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +197 -0
- data/lib/upkeep/rails/testing.rb +258 -0
- data/lib/upkeep/rails.rb +370 -0
- data/lib/upkeep/replay.rb +439 -0
- data/lib/upkeep/runtime.rb +1202 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +375 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +308 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a4b31fcfa6f392fc224821b3fdd7c0ec4b98474cebaa775aacd504172c1c930e
|
|
4
|
+
data.tar.gz: a421c17550f3065d13dad7502984436a065a8d1ede670252f0b2a2187a88a785
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 33947d347624c6e61888db6096f71f36b8abc893fa9cbe3a8a9643c9ef91a33eed220c643086ae65dfdcea6290b5881d44d7986feb8bbc1265906673cc4b9b8b
|
|
7
|
+
data.tar.gz: b358287f543e6bb3ec9e0a254dd453d2ae134575cbfd93a2710f2b58a24ac4d1c4b7b2784498505baede37ce8027ae28340384025bac8b0fa6acc7169abd7934
|
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,311 @@
|
|
|
1
|
+
# Upkeep Rails
|
|
2
|
+
|
|
3
|
+
## 0. Quick Intro
|
|
4
|
+
|
|
5
|
+
Upkeep Rails keeps ordinary Rails pages fresh when the data, request inputs, or
|
|
6
|
+
identity values they used change.
|
|
7
|
+
|
|
8
|
+
A successful HTML GET renders through Rails as usual. Upkeep records the
|
|
9
|
+
templates, records, relations, request values, and identity values that shaped
|
|
10
|
+
the response. Later, an Active Record commit emits facts about what changed.
|
|
11
|
+
Upkeep matches those facts to affected rendered frames and delivers ordinary
|
|
12
|
+
Turbo Stream updates over ActionCable.
|
|
13
|
+
|
|
14
|
+
The design goal is Rails-shaped DX: controllers load state, views render ERB,
|
|
15
|
+
models commit writes, and Upkeep derives the reactive boundary from the Rails
|
|
16
|
+
surfaces it observes. There is no query catalog and no `watch` or `track` DSL.
|
|
17
|
+
|
|
18
|
+
For the deeper runtime model, see [How Upkeep Works](docs/how-it-works.md).
|
|
19
|
+
|
|
20
|
+
## 1. Upkeep vs Vanilla Turbo
|
|
21
|
+
|
|
22
|
+
With vanilla Turbo Streams, write paths often need to name the UI they refresh:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# app/controllers/cards_controller.rb
|
|
26
|
+
class CardsController < ApplicationController
|
|
27
|
+
def create
|
|
28
|
+
@board = Board.find(params[:board_id])
|
|
29
|
+
@card = @board.cards.new(card_params)
|
|
30
|
+
|
|
31
|
+
if @card.save
|
|
32
|
+
@open_card_count = @board.cards.open.count
|
|
33
|
+
|
|
34
|
+
respond_to do |format|
|
|
35
|
+
format.turbo_stream
|
|
36
|
+
end
|
|
37
|
+
else
|
|
38
|
+
render :new, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```erb
|
|
45
|
+
<%# app/views/cards/create.turbo_stream.erb %>
|
|
46
|
+
<%= turbo_stream.append "cards",
|
|
47
|
+
partial: "cards/card",
|
|
48
|
+
locals: { card: @card } %>
|
|
49
|
+
|
|
50
|
+
<%= turbo_stream.update "open_card_count", @open_card_count %>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That follows the standard Turbo Stream shape and avoids a page visit for the
|
|
54
|
+
submitting browser. The tradeoff is that the write path is still coupled to the
|
|
55
|
+
current UI. Adding another dependent page, sidebar, filter, or counter usually
|
|
56
|
+
means revisiting stream templates, controller assignments, callbacks, or
|
|
57
|
+
broadcasts.
|
|
58
|
+
|
|
59
|
+
With Upkeep, the controller can acknowledge the successful write without naming
|
|
60
|
+
stream targets:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class CardsController < ApplicationController
|
|
64
|
+
def create
|
|
65
|
+
board = Board.find(params[:board_id])
|
|
66
|
+
board.cards.create!(card_params)
|
|
67
|
+
|
|
68
|
+
head :no_content
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The submitting Turbo request gets a successful empty response, so it does not
|
|
74
|
+
perform a page visit. The GET that rendered the page already recorded the
|
|
75
|
+
rendered dependencies. When the commit lands, Upkeep selects affected
|
|
76
|
+
subscribers and sends Turbo Streams to the browsers that need them. Validation
|
|
77
|
+
and error rendering stay ordinary application code; the live update path does
|
|
78
|
+
not need to name DOM targets.
|
|
79
|
+
|
|
80
|
+
| Concern | Vanilla Turbo | Upkeep |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| Write path | Names stream targets, partials, counters, or pages. | Commits domain changes. |
|
|
83
|
+
| Read path | Ordinary Rails render. | Ordinary Rails render, captured during HTML GETs. |
|
|
84
|
+
| Browser update | Turbo Streams or page refresh. | Turbo Streams or page refresh. |
|
|
85
|
+
| Boundary | App declares it in stream templates, callbacks, or broadcasts. | Upkeep derives it from rendered Rails surfaces when it can prove safety. |
|
|
86
|
+
| Unsafe shape | App decides how broad to broadcast. | Upkeep raises or warns and refuses the live boundary. |
|
|
87
|
+
|
|
88
|
+
## 2. Install
|
|
89
|
+
|
|
90
|
+
Add the gem:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
gem "upkeep-rails"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run the installer:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
bin/rails generate upkeep:install
|
|
100
|
+
bin/rails db:migrate
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The generator creates subscription tables, writes
|
|
104
|
+
`config/initializers/upkeep.rb`, mounts ActionCable when needed, pins Turbo and
|
|
105
|
+
ActionCable for importmap apps, and imports the browser bootstrap from
|
|
106
|
+
`app/javascript/application.js`.
|
|
107
|
+
|
|
108
|
+
The browser bootstrap is vendored into the host app at
|
|
109
|
+
`app/javascript/upkeep/subscription.js`. After upgrading `upkeep-rails`, rerun
|
|
110
|
+
the installer or compare that file with the generated template.
|
|
111
|
+
|
|
112
|
+
Requirements: Ruby 3.2+, Rails 7.1+, and Turbo 2.0+.
|
|
113
|
+
|
|
114
|
+
## 3. Configure Runtime
|
|
115
|
+
|
|
116
|
+
The generated initializer is the normal place to configure Upkeep:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# config/initializers/upkeep.rb
|
|
120
|
+
Upkeep::Rails.configure do |config|
|
|
121
|
+
app_config = Rails.application.config.upkeep
|
|
122
|
+
|
|
123
|
+
config.enabled = app_config.fetch(:enabled, true)
|
|
124
|
+
config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
|
|
125
|
+
config.delivery_adapter = app_config.fetch(:delivery_adapter, Rails.env.production? ? :active_job : :async)
|
|
126
|
+
config.delivery_queue = app_config.fetch(:delivery_queue, :upkeep_realtime)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use `:active_record` for durable subscription storage in production. The
|
|
131
|
+
generated migration creates the required tables. Use `:memory` for most request
|
|
132
|
+
and system tests, and keep at least one app or CI path on `:active_record` when
|
|
133
|
+
you want to exercise durable rows, schema checks, reload, and cross-process
|
|
134
|
+
lookup.
|
|
135
|
+
|
|
136
|
+
Production apps should use Active Job for delivery so planning, rerendering,
|
|
137
|
+
and broadcasting do not run in the writer's request:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Upkeep::Rails.configure do |config|
|
|
141
|
+
config.delivery_adapter = Rails.env.production? ? :active_job : :async
|
|
142
|
+
config.delivery_queue = :upkeep_realtime
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Configure the app's Active Job backend normally, such as Solid Queue, Sidekiq,
|
|
147
|
+
or GoodJob. ActionCable still needs a shared adapter in multi-process
|
|
148
|
+
deployments because a job worker may not be the process holding the browser's
|
|
149
|
+
WebSocket. Redis, Solid Cable, and PostgreSQL are shared ActionCable adapters.
|
|
150
|
+
|
|
151
|
+
For local debugging, set `config.delivery_adapter = :inline` to run delivery
|
|
152
|
+
immediately.
|
|
153
|
+
|
|
154
|
+
## 4. Configure Identity
|
|
155
|
+
|
|
156
|
+
Pages that depend on a user, account, tenant, locale, or other viewer-specific
|
|
157
|
+
value need an explicit identity bridge.
|
|
158
|
+
|
|
159
|
+
The render side tells Upkeep which value the HTML render read. The subscribe
|
|
160
|
+
side tells Upkeep how the ActionCable connection proves the same value when the
|
|
161
|
+
browser subscribes.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# config/initializers/upkeep.rb
|
|
165
|
+
Upkeep::Rails.configure do |config|
|
|
166
|
+
config.identify :viewer, current: ["Current", :user] do
|
|
167
|
+
subscribe { |connection| connection.current_user }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The matching cable connection must expose that identity:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
# app/channels/application_cable/connection.rb
|
|
176
|
+
module ApplicationCable
|
|
177
|
+
class Connection < ActionCable::Connection::Base
|
|
178
|
+
identified_by :current_user
|
|
179
|
+
|
|
180
|
+
def connect
|
|
181
|
+
self.current_user = User.find_by(id: request.session[:user_id])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Choose the source keyword from the API your render path actually reads:
|
|
188
|
+
|
|
189
|
+
| Render path reads | Declare | Subscribe side returns |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `Current.user` | `current: ["Current", :user]` | the same user, usually `connection.current_user` |
|
|
192
|
+
| Devise or Warden user reads | `warden: :user` | the same Devise user, usually `connection.current_user` |
|
|
193
|
+
| `session[:user_id]` | `session: :user_id` | `connection.session[:user_id]` |
|
|
194
|
+
| `cookies[:account_id]` | `cookie: :account_id` | `connection.cookies[:account_id]` |
|
|
195
|
+
|
|
196
|
+
By default, `nil` means a declared identity boundary is absent. That keeps
|
|
197
|
+
logged-out pages anonymous-public even when a layout checks `Current.user` or
|
|
198
|
+
`session[:user_id]`. If your app has another "not signed in" sentinel, declare
|
|
199
|
+
it:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
Upkeep::Rails.configure do |config|
|
|
203
|
+
config.identify :viewer, session: :user_id do
|
|
204
|
+
absent_if { |value| value.nil? || value == false }
|
|
205
|
+
subscribe { |connection| connection.session[:user_id] }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
If a page reads an undeclared non-absent `CurrentAttributes` or Warden identity,
|
|
211
|
+
Upkeep refuses live registration instead of guessing who may receive updates.
|
|
212
|
+
|
|
213
|
+
## 5. Opaque Values, DX, and Refactors
|
|
214
|
+
|
|
215
|
+
Upkeep only registers live boundaries it can prove. It needs to know which
|
|
216
|
+
future write facts can make a rendered result stale, which target can be
|
|
217
|
+
rerendered or patched, and which observed identity inputs decide whether the
|
|
218
|
+
result can be shared.
|
|
219
|
+
|
|
220
|
+
`Opaque` means application code used something real, but Rails did not expose
|
|
221
|
+
enough structure for Upkeep to answer those questions. Common examples are raw
|
|
222
|
+
SQL predicates, raw joins, raw `from` sources, unknown table aliases, opaque
|
|
223
|
+
order expressions, and render locals that cannot be rebuilt later.
|
|
224
|
+
|
|
225
|
+
The DX is intentionally fail-fast:
|
|
226
|
+
|
|
227
|
+
- development and test raise by default
|
|
228
|
+
- production warns and skips live registration by default
|
|
229
|
+
- `refused_boundary.upkeep` is emitted for instrumentation
|
|
230
|
+
- Upkeep does not widen to a broad unsafe dependency
|
|
231
|
+
|
|
232
|
+
You can choose the behavior explicitly:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Upkeep::Rails.configure do |config|
|
|
236
|
+
config.refused_boundary_behavior = :raise # or :warn
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Most refactors are normal Rails cleanup. Prefer hash conditions and symbolic
|
|
241
|
+
orders when they express the query:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# Before: opaque SQL string order
|
|
245
|
+
Story.order("stories.created_at DESC")
|
|
246
|
+
|
|
247
|
+
# After: structural Rails order
|
|
248
|
+
Story.order(created_at: :desc)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Use Arel when the query needs an operator or correlated condition that hash
|
|
252
|
+
syntax cannot express:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# Before: opaque SQL string predicate
|
|
256
|
+
Story.where("score >= 0")
|
|
257
|
+
|
|
258
|
+
# After: structural Arel predicate
|
|
259
|
+
Story.where(Story.arel_table[:score].gteq(0))
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
# Before: opaque correlated SQL
|
|
264
|
+
HiddenStory.where(Arel.sql("hidden_stories.story_id = stories.id"))
|
|
265
|
+
|
|
266
|
+
# After: structural correlated predicate
|
|
267
|
+
HiddenStory.where(
|
|
268
|
+
HiddenStory.arel_table[:story_id].eq(Story.arel_table[:id])
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
For replay values, keep frame locals and render options to records, relations,
|
|
273
|
+
arrays, hashes, literals, and observed request, session, or cookie values. Avoid
|
|
274
|
+
passing procs, IO handles, open clients, or process-local objects into live
|
|
275
|
+
render boundaries.
|
|
276
|
+
|
|
277
|
+
Some boundaries are intractable on purpose. A full-text search backed by raw
|
|
278
|
+
`tsvector`/`tsquery` SQL has no structural column coverage to prove, and
|
|
279
|
+
rewriting it would defeat the search. When a request should not be made
|
|
280
|
+
reactive at all, opt it out instead of refusing a boundary mid-render. Override
|
|
281
|
+
`upkeep_reactive_request?` in the controller and return `false` for those
|
|
282
|
+
requests:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# app/controllers/stories_controller.rb
|
|
286
|
+
def index
|
|
287
|
+
@stories = params[:query].present? ? Story.search(params[:query]) : Story.recent
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
# Search results use a raw full-text scope Upkeep cannot prove; render them
|
|
293
|
+
# normally but do not register them for live refresh.
|
|
294
|
+
def upkeep_reactive_request?
|
|
295
|
+
return false if params[:query].present?
|
|
296
|
+
|
|
297
|
+
super
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
An opted-out request still runs the action and renders the page; Upkeep simply
|
|
302
|
+
records no subscription, injects no source, and analyzes no boundary — so an
|
|
303
|
+
opaque relation on that request neither raises nor warns. The unfiltered page
|
|
304
|
+
(no `query`) stays reactive. Reach for this only when the boundary is
|
|
305
|
+
genuinely unprovable; prefer the structural refactors above whenever the shape
|
|
306
|
+
*can* be made explicit.
|
|
307
|
+
|
|
308
|
+
The rule of thumb: when Rails and Arel can describe the table, column,
|
|
309
|
+
predicate, order, and value shape, Upkeep can usually reason about it. When the
|
|
310
|
+
shape is hidden inside a string or arbitrary Ruby object, Upkeep refuses the
|
|
311
|
+
live boundary and tells you where to make the shape explicit.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# How Upkeep Works
|
|
2
|
+
|
|
3
|
+
This document explains the runtime model behind Upkeep Rails. It intentionally
|
|
4
|
+
does not cover installation or common configuration; use the
|
|
5
|
+
[README](../README.md) for that public API surface.
|
|
6
|
+
|
|
7
|
+
## Rendered Pages
|
|
8
|
+
|
|
9
|
+
A rendered page is a successful HTML GET that Upkeep can keep fresh. The
|
|
10
|
+
request runs normally through Rails. Upkeep observes the controller, Action View
|
|
11
|
+
rendering, Active Record reads, request inputs, and identity inputs used by the
|
|
12
|
+
response.
|
|
13
|
+
|
|
14
|
+
Upkeep only captures successful HTML responses. Non-HTML responses, redirects,
|
|
15
|
+
failed responses, and explicit non-page interactions continue to behave like
|
|
16
|
+
ordinary Rails responses.
|
|
17
|
+
|
|
18
|
+
## Frames
|
|
19
|
+
|
|
20
|
+
A frame is a rendered page, template, partial, collection render site, or
|
|
21
|
+
fragment with a stable delivery target.
|
|
22
|
+
|
|
23
|
+
Frames let Upkeep refresh a specific part of the page instead of replaying the
|
|
24
|
+
whole response when a narrower update is proven safe. A page frame is the broad
|
|
25
|
+
fallback. A render-site or fragment frame is narrower.
|
|
26
|
+
|
|
27
|
+
Upkeep instruments Action View templates and adds internal `data-upkeep-*`
|
|
28
|
+
markers for page roots, fragment roots, and safe collection render-site
|
|
29
|
+
containers. Normal templates do not need to call helper APIs directly.
|
|
30
|
+
|
|
31
|
+
The `upkeep_frame` helper is an advanced escape hatch for generated or
|
|
32
|
+
helper-built boundaries that cannot be derived from template source. Ordinary
|
|
33
|
+
ERB and partial collections should not need it.
|
|
34
|
+
|
|
35
|
+
## Surfaces
|
|
36
|
+
|
|
37
|
+
A surface is the set of facts about future writes that would make a frame
|
|
38
|
+
stale.
|
|
39
|
+
|
|
40
|
+
For Active Record, Upkeep derives surfaces from observed record attributes,
|
|
41
|
+
rendered collections, and relation shape where Rails exposes structural Arel
|
|
42
|
+
queries. A rendered collection of open cards ordered by position produces a
|
|
43
|
+
surface tied to the cards table, the columns that decide membership and order,
|
|
44
|
+
and the records rendered in that collection.
|
|
45
|
+
|
|
46
|
+
When a write commits, Upkeep compares the write facts with registered surfaces.
|
|
47
|
+
Only frames whose surfaces can be affected are selected for delivery.
|
|
48
|
+
|
|
49
|
+
## Identity Boundaries
|
|
50
|
+
|
|
51
|
+
An identity boundary is state that decides who may receive a live update.
|
|
52
|
+
|
|
53
|
+
Upkeep records observed CurrentAttributes, Warden, session, cookie, and request
|
|
54
|
+
reads for replay and sharing. It does not infer subscriber identity by naming
|
|
55
|
+
convention. The app declares which render-time value maps to which
|
|
56
|
+
subscribe-time ActionCable value.
|
|
57
|
+
|
|
58
|
+
The safety rule is simple: if rendered output depends on a non-public identity,
|
|
59
|
+
only subscribers proving the same identity may receive that output. If Upkeep
|
|
60
|
+
cannot identify the boundary, it refuses live registration rather than sending
|
|
61
|
+
viewer-specific HTML to the wrong browser.
|
|
62
|
+
|
|
63
|
+
Absent identities are public. For example, if a logged-out page reads a nil
|
|
64
|
+
viewer, that nil value can be treated as anonymous-public instead of
|
|
65
|
+
subscriber-specific.
|
|
66
|
+
|
|
67
|
+
## Subscriptions
|
|
68
|
+
|
|
69
|
+
A subscription is the browser's live connection back to the captured page.
|
|
70
|
+
|
|
71
|
+
Upkeep injects a body-scoped `<upkeep-subscription-source>` marker into
|
|
72
|
+
successful HTML responses. The generated browser bootstrap upgrades that marker
|
|
73
|
+
into a Turbo stream source, subscribes over ActionCable, and lets Turbo process
|
|
74
|
+
received stream payloads.
|
|
75
|
+
|
|
76
|
+
The server stores a replayable subscription graph for the rendered page. The
|
|
77
|
+
graph contains frames, dependencies, target metadata, replay recipes, request
|
|
78
|
+
inputs, and identity information needed to plan later updates.
|
|
79
|
+
|
|
80
|
+
## Proven Delivery
|
|
81
|
+
|
|
82
|
+
Proven delivery means Upkeep only emits the narrowest Turbo operation it can
|
|
83
|
+
justify.
|
|
84
|
+
|
|
85
|
+
Depending on the proof available, delivery may use:
|
|
86
|
+
|
|
87
|
+
- `append`
|
|
88
|
+
- `prepend`
|
|
89
|
+
- `remove`
|
|
90
|
+
- `replace`
|
|
91
|
+
- `update`
|
|
92
|
+
- Turbo page `refresh`
|
|
93
|
+
|
|
94
|
+
Render-site replays use Turbo Stream `update method="morph"` against the real
|
|
95
|
+
HTML element Upkeep marked as the render site. The stream template is the
|
|
96
|
+
render site's children, so `update` preserves the legal container element and
|
|
97
|
+
swaps its contents.
|
|
98
|
+
|
|
99
|
+
Page-level fallbacks use Turbo Stream `refresh method="morph"
|
|
100
|
+
scroll="preserve"` instead of replacing `<html>` or writing a new document from
|
|
101
|
+
JavaScript.
|
|
102
|
+
|
|
103
|
+
## Deoptimization
|
|
104
|
+
|
|
105
|
+
A deoptimization means Upkeep can still prove correctness, but not the cheapest
|
|
106
|
+
operation.
|
|
107
|
+
|
|
108
|
+
For example, a collection member update might not have enough proof for a
|
|
109
|
+
single member `replace`, but the enclosing render site might still be safe to
|
|
110
|
+
rerender. In that case, the page remains live and Upkeep falls back to the
|
|
111
|
+
broader proven target.
|
|
112
|
+
|
|
113
|
+
Planning and delivery telemetry record deoptimization reasons so benchmarks and
|
|
114
|
+
tests can separate safety fallbacks from true refusals.
|
|
115
|
+
|
|
116
|
+
## Refused Boundaries
|
|
117
|
+
|
|
118
|
+
A refused boundary means Upkeep cannot prove correctness.
|
|
119
|
+
|
|
120
|
+
If Upkeep cannot answer which future write facts can make a rendered result
|
|
121
|
+
stale, which target can be replayed or patched, or which identity inputs decide
|
|
122
|
+
sharing, it refuses the live boundary.
|
|
123
|
+
|
|
124
|
+
This is intentional. A boundary that cannot be proven should behave like
|
|
125
|
+
ordinary Rails HTML instead of registering a broad or unsafe live dependency.
|
|
126
|
+
|
|
127
|
+
Refusal is different from deoptimization:
|
|
128
|
+
|
|
129
|
+
- refusal: Upkeep cannot prove correctness, so the boundary is not live
|
|
130
|
+
- deoptimization: Upkeep can prove correctness through a broader target, so the
|
|
131
|
+
boundary remains live
|
|
132
|
+
|
|
133
|
+
## What Upkeep Observes
|
|
134
|
+
|
|
135
|
+
Render structure:
|
|
136
|
+
|
|
137
|
+
- Rails-resolved page templates
|
|
138
|
+
- partial and object partial renders
|
|
139
|
+
- Action View-instrumented collection render sites and child fragments
|
|
140
|
+
- polymorphic `render @records` collection shorthand when runtime rendering
|
|
141
|
+
confirms a collection
|
|
142
|
+
- `tag.*` and `content_tag` containers lowered by Herb into ordinary template
|
|
143
|
+
structure
|
|
144
|
+
- single-root fragment targets and legal render-site container targets
|
|
145
|
+
|
|
146
|
+
Template parsing:
|
|
147
|
+
|
|
148
|
+
- Upkeep plans narrow source-derived targets only from templates that pass
|
|
149
|
+
Herb's strict parser.
|
|
150
|
+
- If strict parsing fails but Herb can recover with `strict: false`, Upkeep
|
|
151
|
+
reports the strict parser diagnostics as warnings and may still add broad
|
|
152
|
+
page or fragment root markers.
|
|
153
|
+
- Recovered render sites are diagnostic only. Fix strict warnings before
|
|
154
|
+
expecting narrow collection updates from that template.
|
|
155
|
+
|
|
156
|
+
Data dependencies:
|
|
157
|
+
|
|
158
|
+
- Active Record attribute reads
|
|
159
|
+
- Active Record relation collection renders
|
|
160
|
+
- Active Record callback writes
|
|
161
|
+
- supported bulk `update_all` and `delete_all` writes
|
|
162
|
+
- relation table and column coverage derived from Arel where Rails exposes a
|
|
163
|
+
structural query shape
|
|
164
|
+
|
|
165
|
+
Identity and ambient inputs:
|
|
166
|
+
|
|
167
|
+
- `ActiveSupport::CurrentAttributes` reads
|
|
168
|
+
- Warden and Devise user reads through Warden
|
|
169
|
+
- session and cookie reads
|
|
170
|
+
- request values such as host, path, params, user agent, and remote IP
|
|
171
|
+
- declared Upkeep identities that map observed render-time values to
|
|
172
|
+
ActionCable subscribe-time values
|
|
173
|
+
|
|
174
|
+
## What Upkeep Cannot Capture
|
|
175
|
+
|
|
176
|
+
Upkeep captures reactive facts, not arbitrary Ruby execution. A boundary is
|
|
177
|
+
capturable only when Upkeep can prove the future write facts that affect it,
|
|
178
|
+
the target that can be replayed or patched, and the identity inputs that decide
|
|
179
|
+
whether it can be shared.
|
|
180
|
+
|
|
181
|
+
These surfaces are not capturable today:
|
|
182
|
+
|
|
183
|
+
| Surface | Why it is not capturable | Runtime behavior |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| 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. | Upkeep refuses the live boundary instead of widening to an unsafe dependency. |
|
|
186
|
+
| 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 may be tracked as a page-level dependency, but it does not unlock collection stream planning. |
|
|
187
|
+
| 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. If another observed dependency causes a replay, normal Rails code may read the new value during that replay. |
|
|
188
|
+
| 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. |
|
|
189
|
+
| 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. | Non-replayable values block the narrow replay path until represented as stable data. |
|
|
190
|
+
| Patch targets Upkeep cannot identify in rendered HTML. | Delivery needs a stable page, render-site, fragment, or member target. | Upkeep uses the narrowest proven target. If no safe target exists, the boundary is refused. |
|
|
191
|
+
|
|
192
|
+
## Query Shapes
|
|
193
|
+
|
|
194
|
+
Collection dependencies are accepted only with proven column coverage. Opaque
|
|
195
|
+
predicates or table-only sources are refused instead of widening into broad
|
|
196
|
+
invalidation.
|
|
197
|
+
|
|
198
|
+
Controller materialization is supported when the rendered value keeps a
|
|
199
|
+
structural relation proof:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
def index
|
|
203
|
+
@cards = Card.where(status: "open").order(:position).to_a
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```erb
|
|
208
|
+
<%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Upkeep attaches the collection dependency to the rendered collection boundary,
|
|
212
|
+
not to every controller query. A materialized relation that is never rendered as
|
|
213
|
+
a collection is not a lifecycle dependency by itself.
|
|
214
|
+
|
|
215
|
+
Scalar relation output is tracked as a page-level query dependency:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
@tag_names = Tag.where(active: true).pluck(:name)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Simple plucked columns are live and can select a page replay when they change.
|
|
222
|
+
They are not collection dependencies, so they do not participate in
|
|
223
|
+
append/remove/prepend planning.
|
|
224
|
+
|
|
225
|
+
## Testing Model
|
|
226
|
+
|
|
227
|
+
Use `Upkeep::Rails::Testing` for app-level assertions around subscription
|
|
228
|
+
registration and delivery.
|
|
229
|
+
|
|
230
|
+
Structure tests around behavior, not store internals:
|
|
231
|
+
|
|
232
|
+
- Most request and system tests can run against the memory store. Memory has
|
|
233
|
+
the same public lifecycle as ActiveRecord: registration is fetchable
|
|
234
|
+
immediately, lookup visibility starts on activation, touch updates liveness,
|
|
235
|
+
unregister and prune remove lookup entries, and delivery uses the same
|
|
236
|
+
planner surface.
|
|
237
|
+
- Keep a smaller ActiveRecord-backed integration slice for production-only
|
|
238
|
+
concerns: generated migration shape, schema validation, durable rows,
|
|
239
|
+
reload and rehydration, async persistence, and cross-process lookup.
|
|
240
|
+
- Do not assert implementation details that are unique to one store unless the
|
|
241
|
+
test is explicitly about that implementation. For app behavior, assert the
|
|
242
|
+
marker, activation, streams, broadcasts, and rendered bytes.
|
|
243
|
+
|
|
244
|
+
Useful helpers:
|
|
245
|
+
|
|
246
|
+
- `assert_upkeep_subscription_registered`
|
|
247
|
+
- `upkeep_subscription`
|
|
248
|
+
- `upkeep_stream_names`
|
|
249
|
+
- `activate_upkeep_subscription!`
|
|
250
|
+
- `capture_upkeep_broadcasts`
|
|
251
|
+
- `drain_upkeep_delivery!`
|
|
252
|
+
- `capture_upkeep_change_facts`
|
|
253
|
+
- `upkeep_match_report`
|
|
254
|
+
|
|
255
|
+
Use `capture_upkeep_broadcasts` when an app test needs to assert rendered
|
|
256
|
+
Turbo Stream payloads without depending on the host app's Action Cable test
|
|
257
|
+
adapter. The helper captures Upkeep delivery after planning and rendering, but
|
|
258
|
+
before the transport broadcasts.
|
|
259
|
+
|
|
260
|
+
Use `capture_upkeep_change_facts` and `upkeep_match_report` when debugging an
|
|
261
|
+
invalidation miss. Capture the committed facts produced by the request, then
|
|
262
|
+
dry-run them against the current subscription store. The report returns the
|
|
263
|
+
candidate count, matched count, miss reason, and render targets without
|
|
264
|
+
broadcasting.
|
|
265
|
+
|
|
266
|
+
For structural subscription debugging, call `subscription.explain` or
|
|
267
|
+
`Upkeep::Rails.subscriptions.explain(subscription.id)`. Explanations summarize
|
|
268
|
+
the dependency tables and attributes, identity, frame count, lookup keys, and
|
|
269
|
+
metadata without requiring store-specific instance-variable inspection.
|