upkeep-rails 0.2.5-aarch64-linux-gnu

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,244 @@
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::ControllerTurboFrame
29
+ render_controller_turbo_frame(replay)
30
+ when ::Upkeep::Replay::Template
31
+ render_template(replay)
32
+ when ::Upkeep::Replay::Fragment
33
+ render_fragment(replay)
34
+ when ::Upkeep::Replay::Collection
35
+ render_collection(replay)
36
+ when ::Upkeep::Replay::CollectionMember
37
+ render_collection_member(replay)
38
+ else
39
+ raise "unknown Rails replay recipe type: #{replay.class.name}"
40
+ end
41
+ end
42
+
43
+ def render_controller_page(replay, env: replay.env)
44
+ controller = constantize(replay.controller_class)
45
+ _status, _headers, body = ControllerRuntime.suppress do
46
+ controller.action(replay.action).call(rack_env(env))
47
+ end
48
+ collect_response_body(body)
49
+ end
50
+
51
+ def render_controller_turbo_frame(replay)
52
+ html = render_controller_page(
53
+ replay,
54
+ env: replay.env.merge("HTTP_TURBO_FRAME" => replay.target_id)
55
+ )
56
+ target = Targeting::Target.new("turbo_frame", replay.target_id, "Turbo Frame replay")
57
+ frame_html = Targeting::Extraction.extract_target_html(html, target)
58
+ frame = Nokogiri::HTML5.fragment(frame_html).at_css("turbo-frame")
59
+ frame["src"] = turbo_frame_src(replay.env)
60
+ frame.to_html
61
+ end
62
+
63
+ def turbo_frame_src(env)
64
+ env = env.transform_keys(&:to_s)
65
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
66
+ query = env["QUERY_STRING"].to_s
67
+ query.empty? ? path : "#{path}?#{query}"
68
+ end
69
+
70
+ def render_template(replay)
71
+ template_context = replay_template_context
72
+ renderer_for(replay).render(
73
+ template: replay.template,
74
+ locals: revive_hash(replay.locals, template: template_context),
75
+ assigns: revive_hash(replay.assigns, template: template_context)
76
+ )
77
+ end
78
+
79
+ def render_fragment(replay)
80
+ template_context = replay_template_context
81
+ renderer_for(replay).render(
82
+ partial: partial_path(replay.template),
83
+ locals: revive_hash(replay.locals, template: template_context),
84
+ assigns: revive_hash(replay.assigns, template: template_context)
85
+ )
86
+ end
87
+
88
+ def render_collection(replay)
89
+ template_context = replay_template_context
90
+ options = revive_hash(replay.options, template: template_context)
91
+ collection = revive_value(replay.collection, template: template_context)
92
+
93
+ if replay.derived_partial?
94
+ renderer_for(replay).render(collection)
95
+ else
96
+ renderer_for(replay).render(options.merge(
97
+ partial: replay.partial,
98
+ collection: collection
99
+ ))
100
+ end
101
+ end
102
+
103
+ def render_collection_member(replay)
104
+ template_context = replay_template_context
105
+ options = revive_hash(replay.options, template: template_context)
106
+ record = revive_value(replay.record, template: template_context)
107
+ locals = options.fetch(:locals, {})
108
+ local_name = (options[:as] || inferred_local_name(replay.partial)).to_sym
109
+
110
+ renderer_for(replay).render(
111
+ partial: replay.partial,
112
+ locals: locals.merge(local_name => record)
113
+ )
114
+ end
115
+
116
+ def renderer_for(replay)
117
+ if replay.controller_class
118
+ constantize(replay.controller_class).renderer
119
+ else
120
+ ::ActionController::Base.renderer
121
+ end
122
+ end
123
+
124
+ def rack_env(env)
125
+ env = application_env_config.merge(
126
+ env.each_with_object({}) { |(key, value), copy| copy[key.to_s] = revive_env_value(value) }
127
+ )
128
+ env["rack.input"] = StringIO.new
129
+ env["rack.errors"] ||= StringIO.new
130
+ env
131
+ end
132
+
133
+ def application_env_config
134
+ return {} unless defined?(::Rails) && ::Rails.respond_to?(:application)
135
+
136
+ application = ::Rails.application
137
+ application.respond_to?(:env_config) ? application.env_config : {}
138
+ end
139
+
140
+ def revive_hash(values, template: replay_template_context)
141
+ values = values.entries if values.is_a?(::Upkeep::Replay::HashValue)
142
+
143
+ values.each_with_object({}) do |(key, value), revived|
144
+ revived[key.to_sym] = revive_value(value, template: template)
145
+ end
146
+ end
147
+
148
+ def revive_value(snapshot, template: replay_template_context)
149
+ snapshot = ::Upkeep::Replay.value(snapshot)
150
+
151
+ case snapshot
152
+ when ::Upkeep::Replay::ActiveRecordValue
153
+ constantize(snapshot.model).find(snapshot.id)
154
+ when ::Upkeep::Replay::ActiveRecordRelationValue
155
+ constantize(snapshot.model).find_by_sql(snapshot.sql)
156
+ when ::Upkeep::Replay::ArrayValue
157
+ snapshot.items.map { |item| revive_value(item, template: template) }
158
+ when ::Upkeep::Replay::HashValue
159
+ revive_hash(snapshot.entries, template: template)
160
+ when ::Upkeep::Replay::LiteralValue
161
+ snapshot.value
162
+ when ::Upkeep::Replay::RailsFormBuilderValue
163
+ revive_form_builder(snapshot, template: template)
164
+ else
165
+ raise "unsupported Rails replay value type: #{snapshot.class.name}"
166
+ end
167
+ end
168
+
169
+ def revive_form_builder(snapshot, template:)
170
+ constantize(snapshot.builder_class).new(
171
+ snapshot.object_name,
172
+ revive_value(snapshot.object, template: template),
173
+ template,
174
+ revive_hash(snapshot.options, template: template)
175
+ )
176
+ end
177
+
178
+ def replay_template_context
179
+ ::ActionView::Base.empty
180
+ end
181
+
182
+ def revive_env_value(value)
183
+ return revive_replay_session(value) if replay_session_snapshot?(value)
184
+ return revive_replay_warden(value) if replay_warden_snapshot?(value)
185
+
186
+ case value
187
+ when Hash
188
+ value.transform_values { |nested_value| revive_env_value(nested_value) }
189
+ when Array
190
+ value.map { |nested_value| revive_env_value(nested_value) }
191
+ else
192
+ value
193
+ end
194
+ end
195
+
196
+ def replay_session_snapshot?(value)
197
+ return false unless value.is_a?(Hash)
198
+
199
+ type = value["__upkeep_replay_type"] || value[:__upkeep_replay_type]
200
+ type == "rack_session"
201
+ end
202
+
203
+ def revive_replay_session(snapshot)
204
+ values = snapshot["values"] || snapshot[:values] || {}
205
+ RackSession.new(revive_env_value(values))
206
+ end
207
+
208
+ def replay_warden_snapshot?(value)
209
+ return false unless value.is_a?(Hash)
210
+
211
+ type = value["__upkeep_replay_type"] || value[:__upkeep_replay_type]
212
+ type == "warden"
213
+ end
214
+
215
+ def revive_replay_warden(snapshot)
216
+ users = snapshot["users"] || snapshot[:users] || {}
217
+ revived_users = users.to_h do |scope, user_snapshot|
218
+ value = ::Upkeep::Replay::Value.from_h(user_snapshot)
219
+ [scope, revive_value(value)]
220
+ end
221
+ Runtime::ObservedWarden.new(revived_users)
222
+ end
223
+
224
+ def partial_path(template)
225
+ template.to_s.sub(%r{(^|/)_([^/]+)\z}, "\\1\\2")
226
+ end
227
+
228
+ def inferred_local_name(partial)
229
+ File.basename(partial.to_s).sub(/\A_/, "").to_sym
230
+ end
231
+
232
+ def constantize(name)
233
+ name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
234
+ end
235
+
236
+ def collect_response_body(body)
237
+ body.each.to_a.join
238
+ ensure
239
+ body.close if body.respond_to?(:close)
240
+ end
241
+
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,259 @@
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
+ # on the in-process dispatcher.
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
+ ([Delivery::ActionCableAdapter.subscription_stream_name_for(subscription.id)] +
200
+ subscription.metadata.fetch(:shared_stream_names, [])).uniq
201
+ end
202
+
203
+ # Activates the registered subscription so delivery lookup can find it.
204
+ #
205
+ # @param subscription [Upkeep::Subscriptions::Subscription]
206
+ # @return [Upkeep::Subscriptions::Subscription]
207
+ # @raise [ArgumentError] when no subscription is registered.
208
+ # @raise [Upkeep::Subscriptions::NotFound] when activation fails.
209
+ def activate_upkeep_subscription!(subscription = upkeep_subscription)
210
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
211
+
212
+ activated = Upkeep::Rails.subscriptions.activate(subscription.id)
213
+ raise Upkeep::Subscriptions::NotFound, subscription.id unless activated
214
+
215
+ subscription
216
+ end
217
+
218
+ # Captures rendered Upkeep broadcasts while the block runs. This observes
219
+ # Upkeep after planning/rendering and before the application ActionCable
220
+ # adapter, so tests stay deterministic regardless of the host app's cable
221
+ # adapter.
222
+ #
223
+ # @param subscription [Upkeep::Subscriptions::Subscription]
224
+ # @return [Array<String>]
225
+ # @raise [ArgumentError] when called without a block or subscription.
226
+ def capture_upkeep_broadcasts(subscription = upkeep_subscription, &block)
227
+ raise ArgumentError, "capture_upkeep_broadcasts requires a block" unless block
228
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
229
+
230
+ Upkeep::Rails::Testing.capture_broadcasts(&block)
231
+ end
232
+
233
+ # Drains async Upkeep delivery for deterministic test assertions.
234
+ #
235
+ # @return [void]
236
+ def drain_upkeep_delivery!
237
+ Upkeep::Rails::Testing.drain_delivery!
238
+ end
239
+
240
+ # Captures facts passed into Upkeep delivery while the block runs.
241
+ #
242
+ # @return [Array(Object, Array<Hash>)] the block result and captured facts.
243
+ def capture_upkeep_change_facts(&block)
244
+ raise ArgumentError, "capture_upkeep_change_facts requires a block" unless block
245
+
246
+ Upkeep::Rails::Testing.capture_change_facts(&block)
247
+ end
248
+
249
+ # Returns a dry-run invalidation planner report for one or more change
250
+ # facts against the configured Upkeep subscription store.
251
+ #
252
+ # @param changes [Hash, Array<Hash>] one or more change facts.
253
+ # @return [Hash]
254
+ def upkeep_match_report(changes)
255
+ Upkeep::Rails::Testing.match_report(changes)
256
+ end
257
+ end
258
+ end
259
+ end