upkeep-rails 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. metadata +308 -0
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ # Test helpers for asserting the public Upkeep subscription lifecycle from
6
+ # Rails request, integration, and system tests.
7
+ module Testing
8
+ CHANGE_FACTS_THREAD_KEY = :upkeep_rails_testing_change_facts
9
+ @broadcast_capture_mutex = Mutex.new
10
+ @broadcast_captures = []
11
+
12
+ class << self
13
+ # Drains the async delivery dispatcher when a test needs deterministic
14
+ # broadcast assertions.
15
+ #
16
+ # Production code should not call this; normal app delivery runs
17
+ # through the configured adapter.
18
+ #
19
+ # @return [void]
20
+ def drain_delivery!
21
+ Upkeep::Rails.send(:drain_delivery_dispatcher!)
22
+ end
23
+
24
+ # Captures facts passed into Upkeep delivery while the block runs. This
25
+ # exposes the same committed-change payloads the planner sees, without
26
+ # broadcasting or altering application code.
27
+ #
28
+ # @return [Array(Object, Array<Hash>)] the block result and captured facts.
29
+ def capture_change_facts
30
+ previous = Thread.current[CHANGE_FACTS_THREAD_KEY]
31
+ facts = []
32
+ Thread.current[CHANGE_FACTS_THREAD_KEY] = facts
33
+
34
+ [yield, facts]
35
+ ensure
36
+ Thread.current[CHANGE_FACTS_THREAD_KEY] = previous
37
+ end
38
+
39
+ # Records delivery facts for capture_change_facts. Internal test hook;
40
+ # production delivery calls this only when a capture is active.
41
+ def record_change_facts(changes)
42
+ facts = Thread.current[CHANGE_FACTS_THREAD_KEY]
43
+ return unless facts
44
+
45
+ facts.concat(Array(changes).map { |change| clone_change_fact(change) })
46
+ end
47
+
48
+ # @return [Boolean] true when the current thread is capturing change facts.
49
+ def capturing_change_facts?
50
+ !!Thread.current[CHANGE_FACTS_THREAD_KEY]
51
+ end
52
+
53
+ # Captures rendered Upkeep delivery payloads while the block runs.
54
+ # This observes the batch after planning/rendering and before the
55
+ # app-specific transport adapter, so tests stay deterministic across
56
+ # ActionCable adapters.
57
+ def capture_broadcasts
58
+ broadcasts = []
59
+ broadcast_capture_mutex.synchronize { broadcast_captures << broadcasts }
60
+
61
+ yield
62
+ drain_delivery!
63
+ broadcasts.dup
64
+ ensure
65
+ broadcast_capture_mutex.synchronize { broadcast_captures.delete(broadcasts) } if broadcasts
66
+ end
67
+
68
+ # @return [Boolean] true when any thread is capturing delivery batches.
69
+ def capturing_broadcasts?
70
+ broadcast_capture_mutex.synchronize { broadcast_captures.any? }
71
+ end
72
+
73
+ # Records a rendered delivery batch for active capture_broadcasts
74
+ # blocks. Internal test hook; production delivery calls this only when
75
+ # a capture is active.
76
+ def record_delivery_batch(batch)
77
+ captures = broadcast_capture_mutex.synchronize { broadcast_captures.dup }
78
+ return if captures.empty?
79
+
80
+ bodies = batch.envelopes.map(&:body)
81
+ return if bodies.empty?
82
+
83
+ broadcast_capture_mutex.synchronize do
84
+ captures.each do |capture|
85
+ capture.concat(bodies) if broadcast_captures.include?(capture)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Runs the invalidation planner against committed-change facts without
91
+ # enqueueing delivery or broadcasting.
92
+ #
93
+ # @param changes [Hash, Array<Hash>] one or more change facts.
94
+ # @param store [#reverse_index] subscription store to inspect.
95
+ # @return [Hash] concise planner match report.
96
+ def match_report(changes, store: Upkeep::Rails.subscriptions)
97
+ changes = changes.is_a?(Hash) ? [changes] : Array(changes)
98
+ lookup_payloads = []
99
+ subscription = ActiveSupport::Notifications.subscribe("lookup_subscription_index.upkeep") do |event|
100
+ lookup_payloads << event.payload.dup
101
+ end
102
+
103
+ plan = Upkeep::Invalidation::Planner.new(store: store).plan(changes)
104
+
105
+ {
106
+ candidate_entries: plan.candidate_entries.size,
107
+ matched_entries: plan.matched_entries.size,
108
+ miss_reason: match_miss_reason(plan, changes, lookup_payloads),
109
+ targets: plan.targets.map { |target| match_report_target(target) }
110
+ }
111
+ ensure
112
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
113
+ end
114
+
115
+ private
116
+
117
+ attr_reader :broadcast_capture_mutex, :broadcast_captures
118
+
119
+ def clone_change_fact(change)
120
+ case change
121
+ when Hash
122
+ change.to_h.transform_values { |value| clone_change_fact(value) }
123
+ when Array
124
+ change.map { |value| clone_change_fact(value) }
125
+ else
126
+ begin
127
+ change.dup
128
+ rescue TypeError
129
+ change
130
+ end
131
+ end
132
+ end
133
+
134
+ def match_miss_reason(plan, changes, lookup_payloads)
135
+ return nil if plan.targets.any?
136
+ return "no_changes" if changes.empty?
137
+
138
+ lookup_payloads.reverse_each do |payload|
139
+ return payload.fetch(:miss_reason) if payload.key?(:miss_reason)
140
+ end
141
+
142
+ return "no_matching_subscriber" if plan.candidate_entries.empty?
143
+ return "dependencies_did_not_match_change" if plan.matched_entries.empty?
144
+
145
+ "no_renderable_target"
146
+ end
147
+
148
+ def match_report_target(target)
149
+ {
150
+ subscription_id: target.subscription_id,
151
+ subscriber_id: target.subscriber_id,
152
+ subscriber_ids: target.subscriber_ids,
153
+ target: {
154
+ kind: target.target.kind,
155
+ id: target.target.id,
156
+ reason: target.target.reason
157
+ },
158
+ shared_stream_target: {
159
+ kind: target.shared_stream_target.kind,
160
+ id: target.shared_stream_target.id
161
+ },
162
+ frame_id: target.frame_id,
163
+ identity_signature: target.identity_signature,
164
+ action: target.action,
165
+ matched_dependency_keys: target.matched_dependency_keys,
166
+ deoptimization_reason: target.deoptimization_reason
167
+ }
168
+ end
169
+ end
170
+
171
+ # Asserts that the last successful HTML response injected an Upkeep
172
+ # subscription marker and registered a subscription in the configured
173
+ # store.
174
+ #
175
+ # @param message [String, nil] optional assertion failure message.
176
+ # @return [void]
177
+ def assert_upkeep_subscription_registered(message = nil)
178
+ assert_select "upkeep-subscription-source[data-upkeep-subscription]"
179
+ assert Upkeep::Rails.subscriptions.subscriptions.any?,
180
+ message || "expected Upkeep to register at least one subscription"
181
+ end
182
+
183
+ # Returns the most recently registered Upkeep subscription.
184
+ #
185
+ # @return [Upkeep::Subscriptions::Subscription, nil]
186
+ def upkeep_subscription
187
+ Upkeep::Rails.subscriptions.subscriptions.last
188
+ end
189
+
190
+ # Returns every ActionCable stream name that can receive broadcasts for a
191
+ # subscription, including shared streams.
192
+ #
193
+ # @param subscription [Upkeep::Subscriptions::Subscription]
194
+ # @return [Array<String>]
195
+ # @raise [ArgumentError] when no subscription is registered.
196
+ def upkeep_stream_names(subscription = upkeep_subscription)
197
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
198
+
199
+ ([subscription.metadata.fetch(:stream_name)] + subscription.metadata.fetch(:shared_stream_names, [])).uniq
200
+ end
201
+
202
+ # Activates the registered subscription so delivery lookup can find it.
203
+ #
204
+ # @param subscription [Upkeep::Subscriptions::Subscription]
205
+ # @return [Upkeep::Subscriptions::Subscription]
206
+ # @raise [ArgumentError] when no subscription is registered.
207
+ # @raise [Upkeep::Subscriptions::NotFound] when activation fails.
208
+ def activate_upkeep_subscription!(subscription = upkeep_subscription)
209
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
210
+
211
+ activated = Upkeep::Rails.subscriptions.activate(subscription.id)
212
+ raise Upkeep::Subscriptions::NotFound, subscription.id unless activated
213
+
214
+ subscription
215
+ end
216
+
217
+ # Captures rendered Upkeep broadcasts while the block runs. This observes
218
+ # Upkeep after planning/rendering and before the application ActionCable
219
+ # adapter, so tests stay deterministic regardless of the host app's cable
220
+ # adapter.
221
+ #
222
+ # @param subscription [Upkeep::Subscriptions::Subscription]
223
+ # @return [Array<String>]
224
+ # @raise [ArgumentError] when called without a block or subscription.
225
+ def capture_upkeep_broadcasts(subscription = upkeep_subscription, &block)
226
+ raise ArgumentError, "capture_upkeep_broadcasts requires a block" unless block
227
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
228
+
229
+ Upkeep::Rails::Testing.capture_broadcasts(&block)
230
+ end
231
+
232
+ # Drains async Upkeep delivery for deterministic test assertions.
233
+ #
234
+ # @return [void]
235
+ def drain_upkeep_delivery!
236
+ Upkeep::Rails::Testing.drain_delivery!
237
+ end
238
+
239
+ # Captures facts passed into Upkeep delivery while the block runs.
240
+ #
241
+ # @return [Array(Object, Array<Hash>)] the block result and captured facts.
242
+ def capture_upkeep_change_facts(&block)
243
+ raise ArgumentError, "capture_upkeep_change_facts requires a block" unless block
244
+
245
+ Upkeep::Rails::Testing.capture_change_facts(&block)
246
+ end
247
+
248
+ # Returns a dry-run invalidation planner report for one or more change
249
+ # facts against the configured Upkeep subscription store.
250
+ #
251
+ # @param changes [Hash, Array<Hash>] one or more change facts.
252
+ # @return [Hash]
253
+ def upkeep_match_report(changes)
254
+ Upkeep::Rails::Testing.match_report(changes)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,370 @@
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/activation_token"
8
+ require_relative "rails/delivery_job"
9
+ require_relative "rails/replay"
10
+ require_relative "rails/action_view_capture"
11
+ require_relative "rails/cable"
12
+ require_relative "rails/client_subscription"
13
+ require_relative "rails/controller_runtime"
14
+ require_relative "rails/install"
15
+ require_relative "rails/testing"
16
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
17
+
18
+ module Upkeep
19
+ module Rails
20
+ SUBSCRIPTION_IDENTITY = "upkeep.subscription_identity"
21
+ REQUEST_CAPTURE = "request_capture.upkeep"
22
+ DELIVERY_ENQUEUE = "delivery_enqueue.upkeep"
23
+ DELIVERY_ENQUEUE_ERROR = "delivery_enqueue_error.upkeep"
24
+ INTERNAL_DELIVERY_TABLES = %w[
25
+ upkeep_subscriptions
26
+ upkeep_subscription_index_entries
27
+ upkeep_subscription_shape_index_entries
28
+ ].freeze
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield configuration
37
+ end
38
+
39
+ def subscriptions
40
+ discard_subscription_store! if @subscriptions && subscription_store_config_changed?
41
+ @subscriptions ||= build_subscription_store
42
+ end
43
+
44
+ def transport
45
+ @transport ||= Delivery::BroadcastTransport.new
46
+ end
47
+
48
+ def reset_runtime!
49
+ @delivery_dispatcher&.shutdown
50
+ @delivery_dispatcher = nil
51
+ @subscription_shape_cache&.reset
52
+ @subscription_registrar = nil
53
+ discard_subscription_store! if @subscriptions
54
+ @subscriptions = build_subscription_store
55
+ @subscriptions.reset
56
+ @transport = Delivery::BroadcastTransport.new
57
+ end
58
+
59
+ def register_controller_subscription(controller, capture)
60
+ recorder = capture.recorder
61
+ return unless subscription_response?(controller, capture)
62
+
63
+ decision = Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder)
64
+ unless recorder.reactive?
65
+ instrument_subscription_identity(
66
+ decision,
67
+ registered: false,
68
+ deopt_reason: "refused_boundary",
69
+ refused_boundaries: recorder.refused_boundaries.map(&:reason)
70
+ )
71
+ return
72
+ end
73
+
74
+ identity = Cable::SubscriberIdentity.derive_from_request(
75
+ controller.request,
76
+ recorder: recorder,
77
+ decision: decision
78
+ )
79
+ registration = subscription_registrar.register(
80
+ identity: identity,
81
+ decision: decision,
82
+ recorder: recorder,
83
+ signature: capture.signature,
84
+ metadata: identity_metadata(decision).merge(
85
+ path: controller.request.fullpath,
86
+ stream_name: identity.stream_name
87
+ )
88
+ )
89
+ instrument_subscription_identity(decision, registered: true, subscription: registration.subscription)
90
+
91
+ registration
92
+ rescue Cable::UnidentifiedSubscriber => error
93
+ instrument_subscription_identity(
94
+ decision || Cable::SubscriberIdentity.decision_for(controller.request, recorder: recorder),
95
+ registered: false,
96
+ deopt_reason: "unidentified_identity",
97
+ error: error.message
98
+ )
99
+ nil
100
+ end
101
+
102
+ # Dispatches committed application changes through the configured delivery
103
+ # adapter.
104
+ #
105
+ # ControllerRuntime calls this automatically after non-GET actions. Apps
106
+ # usually should not call it from controllers or models.
107
+ #
108
+ # @param changes [Array<#to_h>] change events to deliver. Defaults to the
109
+ # current runtime change log.
110
+ # @return [Upkeep::Delivery::Transport::DispatchReport]
111
+ def deliver_changes!(changes = Runtime::ChangeLog.drain)
112
+ changes = deliverable_changes(changes)
113
+ record_testing_change_facts(changes)
114
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
115
+
116
+ dispatch_changes(changes)
117
+ rescue StandardError => error
118
+ instrument_delivery_enqueue_error(changes, error)
119
+ Delivery::Transport::DispatchReport.new([])
120
+ end
121
+
122
+ # Delivers committed application changes immediately in the current
123
+ # process.
124
+ #
125
+ # This is used by the inline delivery adapter and Active Job worker. Tests
126
+ # that need deterministic async delivery should use
127
+ # Upkeep::Rails::Testing instead of calling this directly.
128
+ #
129
+ # @param changes [Array<#to_h>] change events to deliver. Defaults to the
130
+ # current runtime change log.
131
+ # @return [Upkeep::Delivery::TurboStreams::Batch, Upkeep::Delivery::Transport::DispatchReport]
132
+ def deliver_changes_now!(changes = Runtime::ChangeLog.drain)
133
+ changes = deliverable_changes(changes)
134
+ record_testing_change_facts(changes)
135
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
136
+
137
+ batch = delivery_batch_for([changes])
138
+ deliver_batch(batch)
139
+ end
140
+
141
+ def validate_configuration!(environment: rails_environment)
142
+ return true unless configuration.enabled
143
+
144
+ validate_subscription_store!(environment: environment)
145
+ true
146
+ end
147
+
148
+ private
149
+
150
+ def delivery_dispatcher
151
+ @delivery_dispatcher ||= Delivery::AsyncDispatcher.new(batch_window: configuration.delivery_batch_window) do |change_sets|
152
+ batch = delivery_batch_for(change_sets)
153
+ deliver_batch(batch)
154
+ end
155
+ end
156
+
157
+ def drain_delivery_dispatcher!
158
+ @delivery_dispatcher&.drain
159
+ end
160
+
161
+ def dispatch_changes(changes)
162
+ payload = {
163
+ adapter: configuration.delivery_adapter,
164
+ queue: configuration.delivery_queue,
165
+ change_count: changes.size
166
+ }
167
+
168
+ ActiveSupport::Notifications.instrument(DELIVERY_ENQUEUE, payload) do
169
+ case configuration.delivery_adapter
170
+ when :active_job
171
+ DeliveryJob.perform_later(changes)
172
+ Delivery::Transport::DispatchReport.new([])
173
+ when :async
174
+ delivery_dispatcher.enqueue(changes)
175
+ when :inline
176
+ deliver_changes_now!(changes)
177
+ end
178
+ end
179
+ end
180
+
181
+ def instrument_delivery_enqueue_error(changes, error)
182
+ ActiveSupport::Notifications.instrument(
183
+ DELIVERY_ENQUEUE_ERROR,
184
+ adapter: configuration.delivery_adapter,
185
+ queue: configuration.delivery_queue,
186
+ change_count: changes.size,
187
+ error_class: error.class.name,
188
+ error_message: error.message
189
+ )
190
+ end
191
+
192
+ def subscription_registrar
193
+ @subscription_registrar ||= Subscriptions::Registrar.new(
194
+ store: subscriptions,
195
+ shape_cache: subscription_shape_cache
196
+ )
197
+ end
198
+
199
+ def subscription_shape_cache
200
+ @subscription_shape_cache ||= Subscriptions::ShapeCache.new
201
+ end
202
+
203
+ def delivery_batch_for(change_sets)
204
+ change_sets = compact_change_sets(change_sets)
205
+ return Delivery::TurboStreams::Batch.new([]) if change_sets.empty?
206
+
207
+ planner = Invalidation::Planner.new(store: subscriptions)
208
+ plans = change_sets.map { |changes| planner.plan(changes) }
209
+ plans = plans.reject { |plan| plan.targets.empty? }
210
+ return Delivery::TurboStreams::Batch.new([]) if plans.empty?
211
+
212
+ Delivery::TurboStreams.new.build_many(plans)
213
+ end
214
+
215
+ def deliver_batch(batch)
216
+ record_testing_delivery_batch(batch)
217
+ transport.deliver(batch)
218
+ end
219
+
220
+ def compact_change_sets(change_sets)
221
+ change_sets
222
+ .map { |changes| deliverable_changes(changes) }
223
+ .reject(&:empty?)
224
+ .uniq { |changes| change_set_key(changes) }
225
+ end
226
+
227
+ def deliverable_changes(changes)
228
+ Array(changes).reject { |change| internal_delivery_change?(change) }
229
+ end
230
+
231
+ def record_testing_change_facts(changes)
232
+ return unless defined?(Testing) && Testing.respond_to?(:record_change_facts)
233
+ return if Testing.respond_to?(:capturing_change_facts?) && !Testing.capturing_change_facts?
234
+
235
+ Testing.record_change_facts(changes)
236
+ end
237
+
238
+ def record_testing_delivery_batch(batch)
239
+ return unless defined?(Testing) && Testing.respond_to?(:record_delivery_batch)
240
+ return if Testing.respond_to?(:capturing_broadcasts?) && !Testing.capturing_broadcasts?
241
+
242
+ Testing.record_delivery_batch(batch)
243
+ end
244
+
245
+ def internal_delivery_change?(change)
246
+ INTERNAL_DELIVERY_TABLES.include?(change_value(change, :table).to_s)
247
+ end
248
+
249
+ def change_set_key(changes)
250
+ changes.map { |change| change_key(change) }.sort
251
+ end
252
+
253
+ def change_key(change)
254
+ [
255
+ change_value(change, :type).to_s,
256
+ change_value(change, :table).to_s,
257
+ change_value(change, :model).to_s,
258
+ change_value(change, :id).to_s,
259
+ Array(change_value(change, :changed_attributes)).map(&:to_s).sort,
260
+ change_value(change, :old_values).inspect,
261
+ change_value(change, :new_values).inspect
262
+ ]
263
+ end
264
+
265
+ def change_value(change, key)
266
+ return unless change.respond_to?(:[])
267
+
268
+ change[key] || change[key.to_s]
269
+ end
270
+
271
+ def discard_subscription_store!
272
+ @subscriptions&.shutdown
273
+ @subscriptions = nil
274
+ @subscription_registrar = nil
275
+ end
276
+
277
+ def identity_metadata(decision)
278
+ {
279
+ identity_mode: decision.mode,
280
+ anonymous: decision.anonymous,
281
+ anonymous_deopt_reason: decision.deopt_reason,
282
+ identity_sources: decision.identity_sources,
283
+ identity_names: decision.identity_names
284
+ }.compact
285
+ end
286
+
287
+ def instrument_subscription_identity(decision, registered:, subscription: nil, deopt_reason: nil, **extra)
288
+ ActiveSupport::Notifications.instrument(
289
+ SUBSCRIPTION_IDENTITY,
290
+ {
291
+ registered: registered,
292
+ subscription_id: subscription&.id,
293
+ subscriber_id: subscription&.subscriber_id,
294
+ identity_mode: decision&.mode,
295
+ anonymous: decision&.anonymous,
296
+ anonymous_deopt_reason: deopt_reason || decision&.deopt_reason,
297
+ identity_sources: decision&.identity_sources,
298
+ identity_names: decision&.identity_names
299
+ }.merge(extra)
300
+ )
301
+ end
302
+
303
+ def subscription_response?(controller, capture)
304
+ controller.request.get? &&
305
+ capture.successful? &&
306
+ capture.html_response? &&
307
+ capture.html.include?("</") &&
308
+ capture.recorder.graph.frame_nodes.any?
309
+ end
310
+
311
+ def build_subscription_store
312
+ case configuration.subscription_store
313
+ when :active_record
314
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
315
+ unless schema_errors.empty?
316
+ raise ConfigurationError,
317
+ active_record_subscription_store_error(schema_errors)
318
+ end
319
+
320
+ Subscriptions::ActiveRecordStore.new
321
+ when :memory
322
+ Subscriptions::Store.new
323
+ end.tap do
324
+ @subscription_store_name = configuration.subscription_store
325
+ end
326
+ end
327
+
328
+ def validate_subscription_store!(environment:)
329
+ if production_environment?(environment) && configuration.subscription_store == :memory
330
+ raise ConfigurationError,
331
+ "Upkeep subscription_store=:memory is only for development/test; production requires :active_record."
332
+ end
333
+
334
+ return true unless production_environment?(environment)
335
+ return true unless configuration.subscription_store == :active_record
336
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
337
+ return true if schema_errors.empty?
338
+
339
+ raise ConfigurationError, active_record_subscription_store_error(schema_errors, production: true)
340
+ end
341
+
342
+ def active_record_subscription_store_error(schema_errors, production: false)
343
+ prefix = if production
344
+ "Upkeep production boot requires compatible upkeep_subscriptions, " \
345
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables " \
346
+ "for subscription_store=:active_record."
347
+ else
348
+ "Upkeep subscription_store=:active_record requires compatible upkeep_subscriptions, " \
349
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables."
350
+ end
351
+
352
+ "#{prefix} Schema errors: #{schema_errors.join("; ")}. Run bin/rails generate upkeep:install " \
353
+ "and bin/rails db:migrate, rebuild stale development/test databases, or set " \
354
+ "config.upkeep.subscription_store = :memory in development/test."
355
+ end
356
+
357
+ def production_environment?(environment)
358
+ environment.to_s == "production"
359
+ end
360
+
361
+ def rails_environment
362
+ ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
363
+ end
364
+
365
+ def subscription_store_config_changed?
366
+ @subscription_store_name != configuration.subscription_store
367
+ end
368
+ end
369
+ end
370
+ end