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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+ require "active_support/hash_with_indifferent_access"
5
+ require "stringio"
6
+
7
+ module Upkeep
8
+ module Rails
9
+ module Replay
10
+ module_function
11
+
12
+ class RackSession < ActiveSupport::HashWithIndifferentAccess
13
+ def enabled? = true
14
+
15
+ def loaded? = true
16
+
17
+ def id = self[:session_id]
18
+
19
+ def id_was = id
20
+ end
21
+
22
+ def render(recipe)
23
+ replay = recipe.replay
24
+
25
+ case replay
26
+ when ::Upkeep::Replay::ControllerPage
27
+ render_controller_page(replay)
28
+ when ::Upkeep::Replay::Template
29
+ render_template(replay)
30
+ when ::Upkeep::Replay::Fragment
31
+ render_fragment(replay)
32
+ when ::Upkeep::Replay::Collection
33
+ render_collection(replay)
34
+ when ::Upkeep::Replay::CollectionMember
35
+ render_collection_member(replay)
36
+ else
37
+ raise "unknown Rails replay recipe type: #{replay.class.name}"
38
+ end
39
+ end
40
+
41
+ def render_controller_page(replay)
42
+ controller = constantize(replay.controller_class)
43
+ _status, _headers, body = ControllerRuntime.suppress do
44
+ controller.action(replay.action).call(rack_env(replay.env))
45
+ end
46
+ collect_response_body(body)
47
+ end
48
+
49
+ def render_template(replay)
50
+ renderer_for(replay).render(
51
+ template: replay.template,
52
+ locals: revive_hash(replay.locals)
53
+ )
54
+ end
55
+
56
+ def render_fragment(replay)
57
+ renderer_for(replay).render(
58
+ partial: partial_path(replay.template),
59
+ locals: revive_hash(replay.locals)
60
+ )
61
+ end
62
+
63
+ def render_collection(replay)
64
+ options = revive_hash(replay.options)
65
+ collection = revive_value(replay.collection)
66
+
67
+ if replay.derived_partial?
68
+ renderer_for(replay).render(collection)
69
+ else
70
+ renderer_for(replay).render(options.merge(
71
+ partial: replay.partial,
72
+ collection: collection
73
+ ))
74
+ end
75
+ end
76
+
77
+ def render_collection_member(replay)
78
+ options = revive_hash(replay.options)
79
+ record = revive_value(replay.record)
80
+ locals = options.fetch(:locals, {})
81
+ local_name = (options[:as] || inferred_local_name(replay.partial)).to_sym
82
+
83
+ renderer_for(replay).render(
84
+ partial: replay.partial,
85
+ locals: locals.merge(local_name => record)
86
+ )
87
+ end
88
+
89
+ def renderer_for(replay)
90
+ if replay.controller_class
91
+ constantize(replay.controller_class).renderer
92
+ else
93
+ ::ActionController::Base.renderer
94
+ end
95
+ end
96
+
97
+ def rack_env(env)
98
+ env = env.each_with_object({}) { |(key, value), copy| copy[key.to_s] = revive_env_value(value) }
99
+ env["rack.input"] = StringIO.new
100
+ env["rack.errors"] ||= StringIO.new
101
+ env
102
+ end
103
+
104
+ def revive_hash(values)
105
+ values = values.entries if values.is_a?(::Upkeep::Replay::HashValue)
106
+
107
+ values.each_with_object({}) do |(key, value), revived|
108
+ revived[key.to_sym] = revive_value(value)
109
+ end
110
+ end
111
+
112
+ def revive_value(snapshot)
113
+ snapshot = ::Upkeep::Replay.value(snapshot)
114
+
115
+ case snapshot
116
+ when ::Upkeep::Replay::ActiveRecordValue
117
+ constantize(snapshot.model).find(snapshot.id)
118
+ when ::Upkeep::Replay::ActiveRecordRelationValue
119
+ constantize(snapshot.model).find_by_sql(snapshot.sql)
120
+ when ::Upkeep::Replay::ArrayValue
121
+ snapshot.items.map { |item| revive_value(item) }
122
+ when ::Upkeep::Replay::HashValue
123
+ revive_hash(snapshot.entries)
124
+ when ::Upkeep::Replay::LiteralValue
125
+ snapshot.value
126
+ else
127
+ raise "unsupported Rails replay value type: #{snapshot.class.name}"
128
+ end
129
+ end
130
+
131
+ def revive_env_value(value)
132
+ return revive_replay_session(value) if replay_session_snapshot?(value)
133
+
134
+ case value
135
+ when Hash
136
+ value.transform_values { |nested_value| revive_env_value(nested_value) }
137
+ when Array
138
+ value.map { |nested_value| revive_env_value(nested_value) }
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def replay_session_snapshot?(value)
145
+ return false unless value.is_a?(Hash)
146
+
147
+ type = value["__upkeep_replay_type"] || value[:__upkeep_replay_type]
148
+ type == "rack_session"
149
+ end
150
+
151
+ def revive_replay_session(snapshot)
152
+ values = snapshot["values"] || snapshot[:values] || {}
153
+ RackSession.new(revive_env_value(values))
154
+ end
155
+
156
+ def partial_path(template)
157
+ template.to_s.sub(%r{(^|/)_([^/]+)\z}, "\\1\\2")
158
+ end
159
+
160
+ def inferred_local_name(partial)
161
+ File.basename(partial.to_s).sub(/\A_/, "").to_sym
162
+ end
163
+
164
+ def constantize(name)
165
+ name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
166
+ end
167
+
168
+ def collect_response_body(body)
169
+ body.each.to_a.join
170
+ ensure
171
+ body.close if body.respond_to?(:close)
172
+ end
173
+
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ module Testing
6
+ def assert_upkeep_subscription_registered(message = nil)
7
+ assert_select "script[data-upkeep-subscription]"
8
+ assert Upkeep::Rails.subscriptions.subscriptions.any?,
9
+ message || "expected Upkeep to register at least one subscription"
10
+ end
11
+
12
+ def upkeep_subscription
13
+ Upkeep::Rails.subscriptions.subscriptions.last
14
+ end
15
+
16
+ def upkeep_stream_names(subscription = upkeep_subscription)
17
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
18
+
19
+ ([subscription.metadata.fetch(:stream_name)] + subscription.metadata.fetch(:shared_stream_names, [])).uniq
20
+ end
21
+
22
+ def capture_upkeep_broadcasts(subscription = upkeep_subscription, &block)
23
+ raise ArgumentError, "capture_upkeep_broadcasts requires a block" unless block
24
+ raise NoMethodError, "include ActionCable::TestHelper before calling capture_upkeep_broadcasts" unless respond_to?(:capture_broadcasts)
25
+
26
+ captures = {}
27
+ nested = upkeep_stream_names(subscription).reverse_each.reduce(block) do |inner, stream_name|
28
+ proc { captures[stream_name] = capture_broadcasts(stream_name, &inner) }
29
+ end
30
+
31
+ nested.call
32
+ captures.values.flatten
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "capture/request"
5
+ require_relative "subscriptions/registrar"
6
+ require_relative "rails/configuration"
7
+ require_relative "rails/replay"
8
+ require_relative "rails/action_view_capture"
9
+ require_relative "rails/cable"
10
+ require_relative "rails/client_subscription"
11
+ require_relative "rails/controller_runtime"
12
+ require_relative "rails/install"
13
+ require_relative "rails/testing"
14
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
15
+
16
+ module Upkeep
17
+ module Rails
18
+ SUBSCRIPTION_IDENTITY = "upkeep.subscription_identity"
19
+ REQUEST_CAPTURE = "request_capture.upkeep"
20
+ INTERNAL_DELIVERY_TABLES = %w[
21
+ upkeep_subscriptions
22
+ upkeep_subscription_index_entries
23
+ ].freeze
24
+
25
+ class << self
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield configuration
32
+ end
33
+
34
+ def subscriptions
35
+ discard_subscription_store! if @subscriptions && subscription_store_config_changed?
36
+ @subscriptions ||= build_subscription_store
37
+ end
38
+
39
+ def transport
40
+ @transport ||= Delivery::BroadcastTransport.new
41
+ end
42
+
43
+ def reset_runtime!
44
+ @delivery_dispatcher&.shutdown
45
+ @delivery_dispatcher = nil
46
+ @subscription_shape_cache&.reset
47
+ @subscription_registrar = nil
48
+ discard_subscription_store! if @subscriptions
49
+ @subscriptions = build_subscription_store
50
+ @subscriptions.reset
51
+ @transport = Delivery::BroadcastTransport.new
52
+ end
53
+
54
+ def register_controller_subscription(controller, capture)
55
+ recorder = capture.recorder
56
+ return unless subscription_response?(controller, capture)
57
+
58
+ decision = Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder)
59
+ unless recorder.reactive?
60
+ instrument_subscription_identity(
61
+ decision,
62
+ registered: false,
63
+ deopt_reason: "refused_boundary",
64
+ refused_boundaries: recorder.refused_boundaries.map(&:reason)
65
+ )
66
+ return
67
+ end
68
+
69
+ identity = Cable::SubscriberIdentity.derive_from_request(
70
+ controller.request,
71
+ recorder: recorder,
72
+ decision: decision
73
+ )
74
+ registration = subscription_registrar.register(
75
+ identity: identity,
76
+ decision: decision,
77
+ recorder: recorder,
78
+ signature: capture.signature,
79
+ metadata: identity_metadata(decision).merge(
80
+ path: controller.request.fullpath,
81
+ stream_name: identity.stream_name
82
+ )
83
+ )
84
+ instrument_subscription_identity(decision, registered: true, subscription: registration.subscription)
85
+
86
+ registration
87
+ rescue Cable::UnidentifiedSubscriber => error
88
+ instrument_subscription_identity(
89
+ decision || Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder),
90
+ registered: false,
91
+ deopt_reason: "unidentified_identity",
92
+ error: error.message
93
+ )
94
+ nil
95
+ end
96
+
97
+ def deliver_changes!(changes = Runtime::ChangeLog.drain)
98
+ changes = deliverable_changes(changes)
99
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
100
+
101
+ delivery_dispatcher.enqueue(changes)
102
+ end
103
+
104
+ def deliver_changes_now!(changes = Runtime::ChangeLog.drain)
105
+ changes = deliverable_changes(changes)
106
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
107
+
108
+ batch = delivery_batch_for([changes])
109
+ transport.deliver(batch)
110
+ end
111
+
112
+ def drain_delivery!
113
+ @delivery_dispatcher&.drain
114
+ end
115
+
116
+ def validate_configuration!(environment: rails_environment)
117
+ return true unless configuration.enabled
118
+
119
+ validate_subscription_store!(environment: environment)
120
+ true
121
+ end
122
+
123
+ private
124
+
125
+ def delivery_dispatcher
126
+ @delivery_dispatcher ||= Delivery::AsyncDispatcher.new do |change_sets|
127
+ batch = delivery_batch_for(change_sets)
128
+ transport.deliver(batch)
129
+ end
130
+ end
131
+
132
+ def subscription_registrar
133
+ @subscription_registrar ||= Subscriptions::Registrar.new(
134
+ store: subscriptions,
135
+ shape_cache: subscription_shape_cache
136
+ )
137
+ end
138
+
139
+ def subscription_shape_cache
140
+ @subscription_shape_cache ||= Subscriptions::ShapeCache.new
141
+ end
142
+
143
+ def delivery_batch_for(change_sets)
144
+ change_sets = compact_change_sets(change_sets)
145
+ return Delivery::TurboStreams::Batch.new([]) if change_sets.empty?
146
+
147
+ planner = Invalidation::Planner.new(store: subscriptions)
148
+ plans = change_sets.map { |changes| planner.plan(changes) }
149
+ plans = plans.reject { |plan| plan.targets.empty? }
150
+ return Delivery::TurboStreams::Batch.new([]) if plans.empty?
151
+
152
+ Delivery::TurboStreams.new.build_many(plans)
153
+ end
154
+
155
+ def compact_change_sets(change_sets)
156
+ change_sets
157
+ .map { |changes| deliverable_changes(changes) }
158
+ .reject(&:empty?)
159
+ .uniq { |changes| change_set_key(changes) }
160
+ end
161
+
162
+ def deliverable_changes(changes)
163
+ Array(changes).reject { |change| internal_delivery_change?(change) }
164
+ end
165
+
166
+ def internal_delivery_change?(change)
167
+ INTERNAL_DELIVERY_TABLES.include?(change_value(change, :table).to_s)
168
+ end
169
+
170
+ def change_set_key(changes)
171
+ changes.map { |change| change_key(change) }.sort
172
+ end
173
+
174
+ def change_key(change)
175
+ [
176
+ change_value(change, :type).to_s,
177
+ change_value(change, :table).to_s,
178
+ change_value(change, :model).to_s,
179
+ change_value(change, :id).to_s,
180
+ Array(change_value(change, :changed_attributes)).map(&:to_s).sort,
181
+ change_value(change, :old_values).inspect,
182
+ change_value(change, :new_values).inspect
183
+ ]
184
+ end
185
+
186
+ def change_value(change, key)
187
+ return unless change.respond_to?(:[])
188
+
189
+ change[key] || change[key.to_s]
190
+ end
191
+
192
+ def discard_subscription_store!
193
+ @subscriptions&.shutdown
194
+ @subscriptions = nil
195
+ @subscription_registrar = nil
196
+ end
197
+
198
+ def identity_metadata(decision)
199
+ {
200
+ identity_mode: decision.mode,
201
+ anonymous: decision.anonymous,
202
+ anonymous_deopt_reason: decision.deopt_reason,
203
+ identity_sources: decision.identity_sources
204
+ }.compact
205
+ end
206
+
207
+ def instrument_subscription_identity(decision, registered:, subscription: nil, deopt_reason: nil, **extra)
208
+ ActiveSupport::Notifications.instrument(
209
+ SUBSCRIPTION_IDENTITY,
210
+ {
211
+ registered: registered,
212
+ subscription_id: subscription&.id,
213
+ subscriber_id: subscription&.subscriber_id,
214
+ identity_mode: decision&.mode,
215
+ anonymous: decision&.anonymous,
216
+ anonymous_deopt_reason: deopt_reason || decision&.deopt_reason,
217
+ identity_sources: decision&.identity_sources
218
+ }.merge(extra)
219
+ )
220
+ end
221
+
222
+ def subscription_response?(controller, capture)
223
+ controller.request.get? &&
224
+ capture.successful? &&
225
+ capture.html_response? &&
226
+ capture.html.include?("</") &&
227
+ capture.recorder.graph.frame_nodes.any?
228
+ end
229
+
230
+ def build_subscription_store
231
+ case configuration.subscription_store
232
+ when :active_record
233
+ unless Subscriptions::ActiveRecordStore.available?(connect: true)
234
+ raise ConfigurationError,
235
+ "Upkeep subscription_store=:active_record requires the upkeep_subscriptions and " \
236
+ "upkeep_subscription_index_entries tables. Run bin/rails generate upkeep:install " \
237
+ "and bin/rails db:migrate, or set config.upkeep.subscription_store = :memory in development/test."
238
+ end
239
+
240
+ Subscriptions::ActiveRecordStore.new
241
+ when :memory
242
+ Subscriptions::Store.new
243
+ end.tap do
244
+ @subscription_store_name = configuration.subscription_store
245
+ end
246
+ end
247
+
248
+ def validate_subscription_store!(environment:)
249
+ if production_environment?(environment) && configuration.subscription_store == :memory
250
+ raise ConfigurationError,
251
+ "Upkeep subscription_store=:memory is only for development/test; production requires :active_record."
252
+ end
253
+
254
+ return true unless production_environment?(environment)
255
+ return true unless configuration.subscription_store == :active_record
256
+ return true if Subscriptions::ActiveRecordStore.available?(connect: true)
257
+
258
+ raise ConfigurationError,
259
+ "Upkeep production boot requires the upkeep_subscriptions and " \
260
+ "upkeep_subscription_index_entries tables for subscription_store=:active_record."
261
+ end
262
+
263
+ def production_environment?(environment)
264
+ environment.to_s == "production"
265
+ end
266
+
267
+ def rails_environment
268
+ ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
269
+ end
270
+
271
+ def subscription_store_config_changed?
272
+ @subscription_store_name != configuration.subscription_store
273
+ end
274
+ end
275
+ end
276
+ end