upkeep-rails 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,187 @@
1
+ # Identity And Sharing
2
+
3
+ Upkeep can share rendered work only when every value that can affect the bytes
4
+ is represented in the graph identity. Rails apps should not declare identity
5
+ dimensions to Upkeep. The runtime derives identity by observing Rails request,
6
+ auth, current-context, and ActionCable surfaces.
7
+
8
+ ## Observed Identity Surfaces
9
+
10
+ CurrentAttributes:
11
+
12
+ ```ruby
13
+ class Current < ActiveSupport::CurrentAttributes
14
+ attribute :user, :account_id
15
+ end
16
+ ```
17
+
18
+ Reads of `Current.user`, `Current.account_id`, and other
19
+ `ActiveSupport::CurrentAttributes` attributes are recorded as identity
20
+ dependencies for the frame that performed the read.
21
+
22
+ Warden and Devise:
23
+
24
+ ```ruby
25
+ request.env["warden"].user(:user)
26
+ ```
27
+
28
+ Warden `user` and `authenticate` calls record the requested scope and canonical
29
+ user identity. Devise applications normally route `current_user` through Warden,
30
+ so the same observer covers that path when the rendered code reaches Warden.
31
+
32
+ Session and cookies:
33
+
34
+ ```ruby
35
+ session[:tenant_id]
36
+ cookies[:theme]
37
+ ```
38
+
39
+ Session and cookie reads record private fingerprints of the values. The values
40
+ are not exposed in delivery reports. Controller page replay keeps the observed
41
+ raw values only when replay needs them; unread session keys and cookies are not
42
+ copied into replay payloads.
43
+
44
+ Request values:
45
+
46
+ ```ruby
47
+ request.subdomain
48
+ request.params
49
+ request.user_agent
50
+ ```
51
+
52
+ Request reads record the key and a private fingerprint of the value. Host,
53
+ subdomain, path, fullpath, request method, user agent, remote IP, and params are
54
+ covered. Replay copies only the observed request values that map to supported
55
+ Rack env keys, such as `request.user_agent`; unread headers such as
56
+ authorization or CSRF-style headers are not copied.
57
+
58
+ ActionCable identifiers:
59
+
60
+ ```ruby
61
+ identified_by :current_user
62
+ ```
63
+
64
+ The subscription stream identity is derived from canonical ActionCable
65
+ connection identifiers and, for the request that created the subscription,
66
+ observed recorder identity dependencies.
67
+
68
+ ## Sharing Rules
69
+
70
+ Public render output can share when:
71
+
72
+ - the selected target is the same page, fragment, or render site;
73
+ - the identity signature is `public`;
74
+ - the replay recipe and sharing inputs are equivalent.
75
+
76
+ Equivalent public targets are grouped before replay, so one render can serve
77
+ multiple subscribers. Delivery still merges identical payload bytes after
78
+ rendering when separate groups converge on the same output.
79
+
80
+ Identity-bound output is partitioned when:
81
+
82
+ - a frame reads `Current.user`, another CurrentAttributes value, Warden/Devise,
83
+ session, cookie, or request state;
84
+ - two subscribers have different observed identity values;
85
+ - the same DOM target renders different bytes under different identity.
86
+
87
+ The delivery layer consumes identity signatures. It does not decide whether a
88
+ subscriber is eligible for a payload.
89
+
90
+ Identity dependencies remain attached to the graph for replay and sharing. They
91
+ do not create lifecycle reverse-index rows, because Active Record commits cannot
92
+ select request, session, cookie, Warden, CurrentAttributes, or ActionCable
93
+ identity reads directly.
94
+
95
+ ## Authorization Example
96
+
97
+ If a card value is visible only when it is under the current user's limit:
98
+
99
+ ```ruby
100
+ class CardPresenter
101
+ def initialize(card)
102
+ @card = card
103
+ end
104
+
105
+ def value_content
106
+ return "Hidden" unless @card.value <= Current.user.value_limit
107
+
108
+ "$#{@card.value}"
109
+ end
110
+ end
111
+ ```
112
+
113
+ The render reads:
114
+
115
+ - `Current.user`, which partitions the frame by current user;
116
+ - `Current.user.value_limit`, which records an Active Record attribute
117
+ dependency for the user row;
118
+ - `card.value`, which records an Active Record attribute dependency for the
119
+ card row.
120
+
121
+ When the card changes, each subscriber gets the payload rendered under their
122
+ own identity. When a user's `value_limit` changes, only the subscriber identity
123
+ bound to that user is selected.
124
+
125
+ ## Ambiguous Identity
126
+
127
+ Identity is ambiguous when render output depends on values outside the observed
128
+ Rails surfaces, such as:
129
+
130
+ - a plain global singleton;
131
+ - an unobserved thread-local;
132
+ - a closure that captured a user or tenant but does not read Current, request,
133
+ Warden, session, or cookies during render;
134
+ - external process state with no observed request or data dependency.
135
+
136
+ The correct shape is to make identity visible through Rails-owned surfaces that
137
+ Upkeep observes: CurrentAttributes, Warden/Devise, session, cookies, request
138
+ values, ActionCable identifiers, or Active Record reads.
139
+
140
+ ## Refused Boundaries
141
+
142
+ Development and test raise for reactive boundaries that would otherwise become
143
+ unsafe or broad. Production-style warning mode records a refused boundary and
144
+ skips subscription registration.
145
+
146
+ Opaque collection predicates:
147
+
148
+ ```ruby
149
+ Card.where("status = ?", "open")
150
+ ```
151
+
152
+ Refactor to structural Active Record or Arel:
153
+
154
+ ```ruby
155
+ Card.where(status: "open")
156
+ ```
157
+
158
+ Opaque pluck expressions:
159
+
160
+ ```ruby
161
+ Tag.pluck("LOWER(name)")
162
+ ```
163
+
164
+ Refactor to a structural column read or render outside Upkeep reactivity:
165
+
166
+ ```ruby
167
+ Tag.pluck(:name).map(&:downcase)
168
+ ```
169
+
170
+ Hidden controller state is not a dependency by itself:
171
+
172
+ ```ruby
173
+ @cards = Card.where(status: "open").to_a
174
+ @cards.count
175
+ ```
176
+
177
+ If the materialized relation is rendered as a collection, Upkeep attaches the
178
+ relation dependency to that render site. If scalar query output affects the
179
+ page, use structural `pluck`. If record attributes affect output, read the
180
+ attributes so Active Record attribute dependencies can select the page.
181
+
182
+ ## No Scope Registry
183
+
184
+ There is no `identity_attributes`, `tracks :tenant_id`, `declared_inputs`, or
185
+ session-key allow-list. A host-maintained registry would either duplicate what
186
+ the runtime can observe or become a silent data-leak risk when the host forgets
187
+ an input. Upkeep's identity claim depends on structural observation.
@@ -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.