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.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -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 +294 -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 +518 -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 +514 -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 +821 -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 +137 -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 +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -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 +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -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 +320 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Identity And Sharing
|
|
2
|
+
|
|
3
|
+
Upkeep can share rendered work only when it can prove that every value affecting
|
|
4
|
+
the bytes is safe to share. It observes Rails ambient state during render, but
|
|
5
|
+
it does not infer subscriber identity from names such as `Current.user` or
|
|
6
|
+
`current_user`.
|
|
7
|
+
|
|
8
|
+
Subscriber identity is an explicit bridge:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
Upkeep::Rails.configure do |config|
|
|
12
|
+
config.identify :viewer, current: ["Current", :user] do
|
|
13
|
+
subscribe { |connection| connection.current_user }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The capture side names the render-time value Upkeep should treat as identity.
|
|
19
|
+
The subscribe side resolves the same identity from the ActionCable connection.
|
|
20
|
+
Both sides are canonicalized before comparison.
|
|
21
|
+
|
|
22
|
+
The arguments have different responsibilities:
|
|
23
|
+
|
|
24
|
+
| Part | Responsibility |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `:viewer` | Names the identity component inside Upkeep. Use a domain role such as `:viewer`, `:account`, `:tenant`, `:organization`, `:locale`, or `:impersonator`. |
|
|
27
|
+
| `current: ["Current", :user]` | Names the render-side source. This example says a render that reads `Current.user` captures that value as `:viewer`. |
|
|
28
|
+
| `subscribe { |connection| connection.current_user }` | Names the ActionCable-side proof. It must resolve the same logical identity when the browser subscribes. |
|
|
29
|
+
|
|
30
|
+
The `subscribe` block receives an Upkeep connection context. It delegates public
|
|
31
|
+
methods such as `current_user` to the ActionCable connection and exposes
|
|
32
|
+
`session` and `cookies` directly. It does not expose the raw ActionCable
|
|
33
|
+
`request` object as a supported API.
|
|
34
|
+
|
|
35
|
+
An identity value may also be absent. By default only `nil` is absent. That
|
|
36
|
+
allows logged-out pages to stay anonymous-public even when they check
|
|
37
|
+
`Current.user`, `warden.user`, or `session[:user_id]`. If an app uses another
|
|
38
|
+
sentinel, the declaration owns that rule:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Upkeep::Rails.configure do |config|
|
|
42
|
+
config.identify :viewer, session: :user_id do
|
|
43
|
+
absent_if { |value| value.nil? || value == false }
|
|
44
|
+
subscribe { |connection| connection.session[:user_id] }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Upkeep evaluates absence while the raw value is in memory. For session and
|
|
50
|
+
cookie identities, the stored dependency still contains a fingerprint, not the
|
|
51
|
+
raw token.
|
|
52
|
+
|
|
53
|
+
## Activation Tokens
|
|
54
|
+
|
|
55
|
+
Every injected `<upkeep-subscription-source>` includes a stateless signed
|
|
56
|
+
activation token for that subscription id. When the browser subscribes, the
|
|
57
|
+
channel verifies the token before it fetches or activates the server
|
|
58
|
+
subscription record.
|
|
59
|
+
|
|
60
|
+
The token answers a transport question: "is this browser activating the exact
|
|
61
|
+
rendered response that received this source?" It is not application identity
|
|
62
|
+
and does not replace the explicit identity bridge below.
|
|
63
|
+
|
|
64
|
+
This keeps first-paint subscription activation independent of Rails session
|
|
65
|
+
creation. Upkeep does not need to create a guest cookie just to let the browser
|
|
66
|
+
activate the subscription it already received in the HTML response.
|
|
67
|
+
|
|
68
|
+
Identified pages still require both checks:
|
|
69
|
+
|
|
70
|
+
- the activation token must match the subscription id;
|
|
71
|
+
- the declared identity must match the ActionCable connection.
|
|
72
|
+
|
|
73
|
+
## Common Identity Shapes
|
|
74
|
+
|
|
75
|
+
Choose the source keyword from the API the render path actually reads.
|
|
76
|
+
|
|
77
|
+
CurrentAttributes, for apps that set and read `Current.user`:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Upkeep::Rails.configure do |config|
|
|
81
|
+
config.identify :viewer, current: ["Current", :user] do
|
|
82
|
+
subscribe { |connection| connection.current_user }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Devise/Warden, for apps whose controllers, helpers, or views call Devise
|
|
88
|
+
`current_user`, `user_signed_in?`, or raw `warden.user(:user)`:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Upkeep::Rails.configure do |config|
|
|
92
|
+
config.identify :viewer, warden: :user do
|
|
93
|
+
subscribe { |connection| connection.current_user }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
module ApplicationCable
|
|
100
|
+
class Connection < ActionCable::Connection::Base
|
|
101
|
+
identified_by :current_user
|
|
102
|
+
|
|
103
|
+
def connect
|
|
104
|
+
self.current_user = env["warden"]&.user(:user)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Use the matching Warden scope for the Devise model being read, such as
|
|
111
|
+
`warden: :admin` for admin pages. The subscribe block can return
|
|
112
|
+
`connection.current_admin` if that is what the cable connection exposes; it
|
|
113
|
+
does not need to call Warden directly as long as it returns the same logical
|
|
114
|
+
admin.
|
|
115
|
+
|
|
116
|
+
If a Devise app copies the authenticated user into `Current.user`, declare the
|
|
117
|
+
source the page actually reads. A page that reads only `Current.user` should use
|
|
118
|
+
`current:`. A page that also calls Devise helpers should either declare the
|
|
119
|
+
Warden source as well or avoid the duplicate render-time identity read.
|
|
120
|
+
|
|
121
|
+
Session, for apps that render directly from `session[:user_id]`:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
Upkeep::Rails.configure do |config|
|
|
125
|
+
config.identify :viewer, session: :user_id do
|
|
126
|
+
subscribe { |connection| connection.session[:user_id] }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Cookie, for apps that render directly from a cookie-backed account or tenant:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
Upkeep::Rails.configure do |config|
|
|
135
|
+
config.identify :account, cookie: :account_id do
|
|
136
|
+
subscribe { |connection| connection.cookies[:account_id] }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The identity name, such as `:viewer` or `:account`, becomes part of the canonical
|
|
142
|
+
identity tuple. If a page captures both `:viewer` and `:account`, the cable
|
|
143
|
+
subscription must match both before live updates are authorized.
|
|
144
|
+
|
|
145
|
+
## What Still Gets Observed
|
|
146
|
+
|
|
147
|
+
Upkeep still observes these ambient surfaces for replay and sharing:
|
|
148
|
+
|
|
149
|
+
- `ActiveSupport::CurrentAttributes` reads.
|
|
150
|
+
- Warden and Devise user reads through Warden.
|
|
151
|
+
- Session and cookie reads.
|
|
152
|
+
- Request values such as host, path, params, user agent, and remote IP.
|
|
153
|
+
|
|
154
|
+
Observed ambient values remain attached to the render graph. They can partition
|
|
155
|
+
render sharing and provide replay inputs, but they do not become subscriber
|
|
156
|
+
identity unless they match a `config.identify` declaration.
|
|
157
|
+
|
|
158
|
+
This distinction matters for Rails layouts. CSRF helpers, flash, preferences,
|
|
159
|
+
or layout code can read session/cookie values incidentally. Those reads should
|
|
160
|
+
not silently turn every page into an authenticated identity boundary.
|
|
161
|
+
|
|
162
|
+
## Failure Rules
|
|
163
|
+
|
|
164
|
+
If a page reads undeclared non-absent `CurrentAttributes` or Warden identity,
|
|
165
|
+
Upkeep refuses live registration instead of guessing:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
subscription has identity dependencies but no declared Upkeep identity mapping
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
That refusal is intentional. A user-specific page that cannot be matched on the
|
|
172
|
+
ActionCable side should behave like ordinary Rails HTML, not register an unsafe
|
|
173
|
+
live subscription.
|
|
174
|
+
|
|
175
|
+
If the cable subscription resolves a different value, the channel rejects the
|
|
176
|
+
subscription:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
captured :viewer = User#123
|
|
180
|
+
subscribe :viewer = User#456
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
If an identity block returns an unsupported object, Upkeep rejects it. Identity
|
|
184
|
+
values should be stable values: Active Record records, GlobalID-capable values,
|
|
185
|
+
strings, symbols, numbers, booleans, or arrays/hashes of those.
|
|
186
|
+
|
|
187
|
+
## Sharing Rules
|
|
188
|
+
|
|
189
|
+
Public render output can share when:
|
|
190
|
+
|
|
191
|
+
- the selected target is the same page, fragment, or render site;
|
|
192
|
+
- the identity signature is `public`;
|
|
193
|
+
- the replay recipe and sharing inputs are equivalent.
|
|
194
|
+
|
|
195
|
+
Equivalent public targets are grouped before replay, so one render can serve
|
|
196
|
+
multiple subscribers. Delivery still merges identical payload bytes after
|
|
197
|
+
rendering when separate groups converge on the same output.
|
|
198
|
+
|
|
199
|
+
Identity-bound output is partitioned when:
|
|
200
|
+
|
|
201
|
+
- a frame reads declared identity such as `:viewer` or `:account`;
|
|
202
|
+
- a frame reads ambient state that changes its identity signature;
|
|
203
|
+
- two subscribers have different observed identity values;
|
|
204
|
+
- the same DOM target renders different bytes under different identity.
|
|
205
|
+
|
|
206
|
+
Identity dependencies remain attached to the graph for replay and sharing. They
|
|
207
|
+
do not create lifecycle reverse-index rows, because Active Record commits cannot
|
|
208
|
+
select request, session, cookie, Warden, CurrentAttributes, or ActionCable
|
|
209
|
+
identity reads directly.
|
|
210
|
+
|
|
211
|
+
## Authorization Example
|
|
212
|
+
|
|
213
|
+
If a card value is visible only when it is under the current user's limit:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
class CardPresenter
|
|
217
|
+
def initialize(card)
|
|
218
|
+
@card = card
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def value_content
|
|
222
|
+
return "Hidden" unless @card.value <= Current.user.value_limit
|
|
223
|
+
|
|
224
|
+
"$#{@card.value}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Declare the identity bridge:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
Upkeep::Rails.configure do |config|
|
|
233
|
+
config.identify :viewer, current: ["Current", :user] do
|
|
234
|
+
subscribe { |connection| connection.current_user }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The render reads:
|
|
240
|
+
|
|
241
|
+
- declared identity `:viewer` from `Current.user`;
|
|
242
|
+
- `Current.user.value_limit`, which records an Active Record attribute
|
|
243
|
+
dependency for the user row;
|
|
244
|
+
- `card.value`, which records an Active Record attribute dependency for the
|
|
245
|
+
card row.
|
|
246
|
+
|
|
247
|
+
When the card changes, each subscriber gets the payload rendered under their
|
|
248
|
+
own identity. When a user's `value_limit` changes, only the subscription bound
|
|
249
|
+
to that user is selected.
|
|
250
|
+
|
|
251
|
+
## Ambiguous Identity
|
|
252
|
+
|
|
253
|
+
Identity is ambiguous when render output depends on values outside declared or
|
|
254
|
+
observed Rails surfaces, such as:
|
|
255
|
+
|
|
256
|
+
- a plain global singleton;
|
|
257
|
+
- an unobserved thread-local;
|
|
258
|
+
- a closure that captured a user or tenant but does not read Current, request,
|
|
259
|
+
Warden, session, or cookies during render;
|
|
260
|
+
- external process state with no observed request or data dependency.
|
|
261
|
+
|
|
262
|
+
The correct shape is to make identity visible through Rails-owned surfaces that
|
|
263
|
+
Upkeep observes, then declare the ActionCable subscribe bridge with
|
|
264
|
+
`config.identify`.
|
|
265
|
+
|
|
266
|
+
## Refused Boundaries
|
|
267
|
+
|
|
268
|
+
Development and test raise for reactive boundaries that would otherwise become
|
|
269
|
+
unsafe or broad. Production-style warning mode records a refused boundary and
|
|
270
|
+
skips subscription registration.
|
|
271
|
+
|
|
272
|
+
Opaque collection predicates:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
Card.where("status = ?", "open")
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Refactor to structural Active Record or Arel:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
Card.where(status: "open")
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Opaque pluck expressions:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
Tag.pluck("LOWER(name)")
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Refactor to a structural column read or render outside Upkeep reactivity:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
Tag.pluck(:name).map(&:downcase)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Hidden controller state is not a dependency by itself:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
@cards = Card.where(status: "open").to_a
|
|
300
|
+
@cards.count
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
If the materialized relation is rendered as a collection, Upkeep attaches the
|
|
304
|
+
relation dependency to that render site. If scalar query output affects the
|
|
305
|
+
page, use structural `pluck`. If record attributes affect output, read the
|
|
306
|
+
attributes so Active Record attribute dependencies can select the page.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Query Dependencies
|
|
2
|
+
|
|
3
|
+
Active Record collection dependencies are derived from relation shape. Upkeep
|
|
4
|
+
does not ask the app to register queries, predicates, params, or invalidation
|
|
5
|
+
keys.
|
|
6
|
+
|
|
7
|
+
## Capture Point
|
|
8
|
+
|
|
9
|
+
When Rails renders an Active Record relation as a collection, Upkeep analyzes
|
|
10
|
+
the relation before creating the collection dependency:
|
|
11
|
+
|
|
12
|
+
```erb
|
|
13
|
+
<%= render partial: "cards/card", collection: @board.cards.order(:position), as: :card %>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The dependency stores:
|
|
17
|
+
|
|
18
|
+
- the primary model table;
|
|
19
|
+
- the relation SQL digest;
|
|
20
|
+
- table and column coverage derived from Arel;
|
|
21
|
+
- proven column coverage;
|
|
22
|
+
- replay inputs for the render-site recipe.
|
|
23
|
+
|
|
24
|
+
If a controller materializes the relation first, the materialized collection
|
|
25
|
+
retains relation provenance:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
def index
|
|
29
|
+
@cards = Card.where(status: params.fetch(:status)).order(:position).to_a
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```erb
|
|
34
|
+
<%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The render-site consumes that provenance and records the collection dependency
|
|
38
|
+
at the rendered collection boundary. `Relation#exec_queries` does not register
|
|
39
|
+
an Active Record collection dependency by itself.
|
|
40
|
+
|
|
41
|
+
If a controller materializes a relation and never renders that relation-backed
|
|
42
|
+
collection, the materialization is not a lifecycle dependency. Upkeep relies on
|
|
43
|
+
the surfaces that actually affect output: rendered relation collections,
|
|
44
|
+
scalar query dependencies, and record attribute reads.
|
|
45
|
+
|
|
46
|
+
Scalar relation queries are page-level query dependencies, not collection
|
|
47
|
+
dependencies:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
def index
|
|
51
|
+
@tag_names = Tag.where(active: true).pluck(:name)
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This records an `active_record_query` dependency. A matching commit can replay
|
|
56
|
+
the page because scalar query output may affect the page, but the dependency is
|
|
57
|
+
not eligible for collection append/remove/prepend/member-replace planning.
|
|
58
|
+
Simple plucked columns are included in the query dependency. Opaque pluck
|
|
59
|
+
expressions, such as raw SQL expressions, are refused instead of becoming broad
|
|
60
|
+
collection dependencies.
|
|
61
|
+
|
|
62
|
+
## Collection Coverage
|
|
63
|
+
|
|
64
|
+
Upkeep records a collection dependency only when it can see the involved tables
|
|
65
|
+
and columns:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Card.where(board_id: board.id, status: "open").order(:position)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This records the `cards` table with the primary key plus the predicate and
|
|
72
|
+
ordering columns. Updates to unrelated columns do not select the collection
|
|
73
|
+
dependency.
|
|
74
|
+
|
|
75
|
+
Opaque predicates are not registered as broad collection dependencies:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Card.where("status = ?", "open").order(:position)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In development/test this raises `Upkeep::ActiveRecordQuery::OpaqueRelationError`
|
|
82
|
+
while capturing the reactive render. In production-style warning mode, Upkeep
|
|
83
|
+
warns, marks the boundary refused, and skips subscription registration instead
|
|
84
|
+
of broadening to the whole `cards` table.
|
|
85
|
+
|
|
86
|
+
Preferred refactor:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Card.where(status: "open").order(:position)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Opaque table sources are not reactive:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Card
|
|
96
|
+
.joins("INNER JOIN authors ON authors.id = cards.author_id")
|
|
97
|
+
.where("authors.name = ?", "Ada")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This raises `Upkeep::ActiveRecordQuery::OpaqueRelationError` while capturing
|
|
101
|
+
the reactive render. Upkeep does not create a dependency for a relation whose
|
|
102
|
+
table sources cannot be structurally proven.
|
|
103
|
+
|
|
104
|
+
Preferred refactor:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
Card.joins(:author).where(authors: { name: "Ada" })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Reverse Index Shape
|
|
111
|
+
|
|
112
|
+
Collection dependencies enter the invalidation reverse index through concrete
|
|
113
|
+
table and column lookup keys:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Card.where(status: "open").order(:position)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This registers collection lookup keys for the proven `cards` columns, such as
|
|
120
|
+
`status`, `id`, and `position`. A lifecycle event for `cards.title` does not
|
|
121
|
+
select this collection unless `title` was part of the proven relation shape.
|
|
122
|
+
|
|
123
|
+
`active_record_query` dependencies use the same table and column lookup shape,
|
|
124
|
+
but target the nearest page frame. They intentionally do not unlock collection
|
|
125
|
+
operation planning, because scalar query output has no rendered collection
|
|
126
|
+
membership boundary.
|
|
127
|
+
|
|
128
|
+
Record-specific attribute reads index the exact table, id, and attribute:
|
|
129
|
+
|
|
130
|
+
```erb
|
|
131
|
+
<%= card.title %>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Bulk or otherwise id-less attribute changes use an any-id attribute lookup key
|
|
135
|
+
for the changed column. Record-specific reads no longer also register an any-id
|
|
136
|
+
lookup row.
|
|
137
|
+
|
|
138
|
+
Request, session, cookie, CurrentAttributes, Warden, and ActionCable identity
|
|
139
|
+
dependencies are graph inputs, not lifecycle invalidation keys. They partition
|
|
140
|
+
replay and sharing, but an Active Record commit cannot select them directly, so
|
|
141
|
+
they do not occupy reverse-index lookup rows.
|
|
142
|
+
|
|
143
|
+
## Joins And Aliases
|
|
144
|
+
|
|
145
|
+
Association joins are structural:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Card.joins(:author).where(authors: { name: "Ada" }).order(:position)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Upkeep records columns from both the `cards` table and the `authors` table.
|
|
152
|
+
Aliased self-joins map alias columns back to the underlying table so committed
|
|
153
|
+
changes use real table names from Active Record callbacks.
|
|
154
|
+
|
|
155
|
+
## Bulk Writes
|
|
156
|
+
|
|
157
|
+
Bulk writes are observed through Active Record relation methods:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
Card.where(board_id: board.id).update_all(status: "done")
|
|
161
|
+
Card.where(status: "archived").delete_all
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For hash updates, Upkeep records the changed keys. For string updates, Upkeep
|
|
165
|
+
records all columns on the model table because parsing assignment SQL would not
|
|
166
|
+
be a structural proof. Relation predicate coverage is derived through the same
|
|
167
|
+
Arel analyzer used for collection reads. Bulk write events may carry table
|
|
168
|
+
coverage for opaque write predicates, but that coverage describes the write
|
|
169
|
+
event; it is not a collection subscription fallback.
|
|
170
|
+
|
|
171
|
+
## Provable Collection Operations
|
|
172
|
+
|
|
173
|
+
Collection changes can sometimes be delivered as narrow Turbo Stream operations
|
|
174
|
+
instead of replacing the whole render site. These operations are optimizations,
|
|
175
|
+
not the correctness path.
|
|
176
|
+
|
|
177
|
+
Operation proofs currently cover:
|
|
178
|
+
|
|
179
|
+
- `append` for creates when replay proves the new record appears after all
|
|
180
|
+
previously rendered member ids;
|
|
181
|
+
- `prepend` for creates when replay proves the new record appears before all
|
|
182
|
+
previously rendered member ids;
|
|
183
|
+
- `remove` for destroys when the prior rendered member target is known;
|
|
184
|
+
- stable member `replace` for updates when the row remains in the relation and
|
|
185
|
+
replay proves the rendered member order did not change.
|
|
186
|
+
|
|
187
|
+
Each operation requires a collection render-site recipe, an Active Record
|
|
188
|
+
relation snapshot, a primary key, proven column-level relation coverage, and a
|
|
189
|
+
relation shape without limit, offset, distinct, group, or having. If any
|
|
190
|
+
requirement fails, delivery deoptimizes to the canonical render-site
|
|
191
|
+
replacement recipe and reports the reason.
|
|
192
|
+
|
|
193
|
+
## Debugging Surface
|
|
194
|
+
|
|
195
|
+
Collection dependency metadata reports:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
{
|
|
199
|
+
primary_table: "cards",
|
|
200
|
+
table_columns: { "cards" => ["board_id", "id", "position"] },
|
|
201
|
+
coverage: "columns",
|
|
202
|
+
sql: "SELECT ..."
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The important debugging question is the coverage level. `columns` is narrow and
|
|
207
|
+
accepted for collection dependencies. Opaque predicates or table sources raise
|
|
208
|
+
a guided error in development/test or become refused boundaries in
|
|
209
|
+
production-style warning mode instead of registering a broad invalidation
|
|
210
|
+
dependency.
|
|
211
|
+
|
|
212
|
+
Operation planning also reports whether a narrow collection operation was
|
|
213
|
+
proved or deoptimized to replacement. These diagnostics explain implementation
|
|
214
|
+
choices; benchmark summaries remain responsible for comparing runtime cost
|
|
215
|
+
against Turbo refresh and manually written Turbo Stream operations.
|
|
216
|
+
|
|
217
|
+
## No Query Catalog
|
|
218
|
+
|
|
219
|
+
There is no Rails-side equivalent of:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
tracks_query :open_cards
|
|
223
|
+
declared_inputs :board_id, :status
|
|
224
|
+
config.identity_attributes = [...]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The runtime derives what it can from Active Record and Arel. When it cannot
|
|
228
|
+
prove the table sources for a relation, it rejects the reactive boundary with
|
|
229
|
+
guidance to rewrite the query through structural Active Record/Arel joins or
|
|
230
|
+
move the render outside Upkeep reactivity.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Subscription Store Contract
|
|
2
|
+
|
|
3
|
+
`Upkeep::Subscriptions` uses duck typing for subscription storage. Callers
|
|
4
|
+
should depend on the store protocol below, not on whether the implementation is
|
|
5
|
+
in-memory or Active Record backed.
|
|
6
|
+
|
|
7
|
+
The contract is enforced by `test/support/subscription_store_contract.rb`.
|
|
8
|
+
Implementation-specific tests may add storage behavior, but they should not
|
|
9
|
+
weaken these lifecycle rules.
|
|
10
|
+
|
|
11
|
+
## Lifecycle
|
|
12
|
+
|
|
13
|
+
- `register(subscriber_id:, recorder:, metadata: {}, entries: nil)` creates a
|
|
14
|
+
pending subscription and returns it. The subscription is fetchable immediately.
|
|
15
|
+
- `activate(id)` makes the subscription lookup-visible and returns `true`.
|
|
16
|
+
Missing ids return `false`.
|
|
17
|
+
- `fetch(id)` returns a subscription or raises
|
|
18
|
+
`Upkeep::Subscriptions::NotFound`.
|
|
19
|
+
- `unregister(ids)` removes subscriptions from fetch and lookup state. It is
|
|
20
|
+
idempotent for callers and returns the number of ids requested for removal.
|
|
21
|
+
- `touch(id, now:)` updates liveness metadata and raises `NotFound` for missing
|
|
22
|
+
ids.
|
|
23
|
+
- `prune_stale!(older_than:)` removes subscriptions whose liveness timestamp is
|
|
24
|
+
older than the threshold and returns the number removed.
|
|
25
|
+
|
|
26
|
+
Registration and lookup visibility are intentionally separate. A rendered HTML
|
|
27
|
+
response can register a durable subscription before the browser proves that it
|
|
28
|
+
opened the matching ActionCable stream. Delivery planning should see only
|
|
29
|
+
activated subscriptions.
|
|
30
|
+
|
|
31
|
+
## Lookup Surface
|
|
32
|
+
|
|
33
|
+
- `reverse_index.entries_for(changes)` is the only lookup surface used by the
|
|
34
|
+
invalidation planner.
|
|
35
|
+
- Active subscriptions are visible to lookup.
|
|
36
|
+
- Pending subscriptions are observable in diagnostics but not returned for
|
|
37
|
+
delivery.
|
|
38
|
+
- Identity-free subscriptions may be represented as cohort entries, where one
|
|
39
|
+
dependency entry can represent many subscriber ids.
|
|
40
|
+
|
|
41
|
+
## Durability Hooks
|
|
42
|
+
|
|
43
|
+
- `drain` flushes durable work. It is meaningful for Active Record and a no-op
|
|
44
|
+
success for memory.
|
|
45
|
+
- `shutdown` stops durable resources. It is meaningful for Active Record and a
|
|
46
|
+
no-op success for memory.
|
|
47
|
+
- `reset` clears all store state.
|
|
48
|
+
- `summary` reports subscription counts and reverse-index diagnostics. Active
|
|
49
|
+
Record summaries split active, pending, direct persistent, and shape
|
|
50
|
+
persistent index state.
|
|
51
|
+
|
|
52
|
+
## Implementation Boundaries
|
|
53
|
+
|
|
54
|
+
The memory store is an in-process contract implementation. It should stay small
|
|
55
|
+
and deterministic.
|
|
56
|
+
|
|
57
|
+
The Active Record store adds:
|
|
58
|
+
|
|
59
|
+
- durable subscription rows,
|
|
60
|
+
- durable direct and shape index rows,
|
|
61
|
+
- async writes,
|
|
62
|
+
- reload and cross-process lookup,
|
|
63
|
+
- schema validation and production suitability.
|
|
64
|
+
|
|
65
|
+
Those storage mechanics are implementation details. The lifecycle contract above
|
|
66
|
+
is the shared behavior callers can rely on.
|