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.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -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.