upkeep-rails 0.1.9 → 0.1.12

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +105 -195
  3. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  4. data/docs/handoffs/_archive/2026-06-10-main.md +47 -0
  5. data/docs/how-it-works.md +8 -0
  6. data/lib/generators/upkeep/install/install_generator.rb +59 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +6 -5
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +8 -7
  9. data/lib/upkeep/delivery/turbo_streams.rb +40 -15
  10. data/lib/upkeep/dependencies.rb +55 -5
  11. data/lib/upkeep/invalidation/planner.rb +48 -10
  12. data/lib/upkeep/rails/cable/channel.rb +27 -5
  13. data/lib/upkeep/rails/cable/subscriber_identity.rb +21 -1
  14. data/lib/upkeep/rails/client_subscription.rb +12 -12
  15. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  16. data/lib/upkeep/rails/configuration.rb +9 -16
  17. data/lib/upkeep/rails/controller_runtime.rb +17 -0
  18. data/lib/upkeep/rails/railtie.rb +1 -10
  19. data/lib/upkeep/rails/testing.rb +1 -1
  20. data/lib/upkeep/rails.rb +58 -17
  21. data/lib/upkeep/runtime.rb +39 -2
  22. data/lib/upkeep/shared_streams.rb +17 -3
  23. data/lib/upkeep/subscriptions/active_record_store.rb +53 -62
  24. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +6 -3
  25. data/lib/upkeep/subscriptions/active_registry.rb +0 -7
  26. data/lib/upkeep/subscriptions/base_store.rb +106 -0
  27. data/lib/upkeep/subscriptions/layered_reverse_index.rb +9 -13
  28. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  29. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +4 -1
  30. data/lib/upkeep/subscriptions/store.rb +38 -64
  31. data/lib/upkeep/version.rb +1 -1
  32. data/upkeep-rails.gemspec +0 -1
  33. metadata +7 -24
  34. data/lib/upkeep/rails/delivery_job.rb +0 -29
  35. data/lib/upkeep/subscriptions/async_durable_writer.rb +0 -131
@@ -54,10 +54,13 @@ module Upkeep
54
54
  end
55
55
 
56
56
  class ActiveRecordAttribute < Base
57
- def initialize(table:, id:, attribute:, model: nil)
57
+ def initialize(table:, id:, attribute:, model: nil, scope: nil)
58
+ scope = normalize_scope(scope)
59
+ key = { table: table, id: id, attribute: attribute }
60
+ key[:scope] = scope if scope
58
61
  super(
59
62
  source: :active_record_attribute,
60
- key: { table: table, id: id, attribute: attribute },
63
+ key: key,
61
64
  metadata: { model: model }.compact
62
65
  )
63
66
  end
@@ -65,7 +68,8 @@ module Upkeep
65
68
  def matches_change?(change)
66
69
  key.fetch(:table) == change.fetch(:table) &&
67
70
  (key.fetch(:id).nil? || !change[:id] || key.fetch(:id) == change[:id]) &&
68
- change.fetch(:changed_attributes, []).include?(key.fetch(:attribute))
71
+ change.fetch(:changed_attributes, []).include?(key.fetch(:attribute)) &&
72
+ scope_matches_change?(change)
69
73
  end
70
74
 
71
75
  def precision
@@ -75,6 +79,37 @@ module Upkeep
75
79
  def narrow_frame_safe?
76
80
  true
77
81
  end
82
+
83
+ private
84
+
85
+ def normalize_scope(scope)
86
+ return nil unless scope.is_a?(Hash)
87
+
88
+ normalized = scope.each_with_object({}) do |(column, value), result|
89
+ result[column.to_s] = value unless value.nil?
90
+ end
91
+ normalized.empty? ? nil : normalized
92
+ end
93
+
94
+ def scope_matches_change?(change)
95
+ scope = key[:scope]
96
+ return true unless scope
97
+
98
+ scope.all? do |column, value|
99
+ observed = observed_scope_values(change, column)
100
+ observed.empty? || observed.any? { |observed_value| scope_values_equal?(observed_value, value) }
101
+ end
102
+ end
103
+
104
+ def observed_scope_values(change, column)
105
+ [change[:new_values], change[:old_values]].compact.filter_map do |values|
106
+ values[column] || values[column.to_sym]
107
+ end
108
+ end
109
+
110
+ def scope_values_equal?(observed, expected)
111
+ observed == expected || observed.to_s == expected.to_s
112
+ end
78
113
  end
79
114
 
80
115
  class ActiveRecordCollection < Base
@@ -363,12 +398,19 @@ module Upkeep
363
398
  end
364
399
 
365
400
  class RequestValue < Identity
401
+ # Request values that are stable for a deployment/connection rather than a viewer.
402
+ # They never partition subscribers, but their fingerprints are folded into shared
403
+ # stream names (see SharedStreams.deployment_signature_for) so viewers with
404
+ # different values can never share a stream.
405
+ DEPLOYMENT_STABLE_KEYS = %w[host port protocol ssl request_method].freeze
406
+
366
407
  def initialize(key:, value:)
367
408
  super(
368
409
  source: :request,
369
410
  key: key.to_s,
370
411
  value: Dependencies.private_fingerprint(value),
371
- metadata: { key: key.to_s, value_class: value.class.name }
412
+ metadata: { key: key.to_s, value_class: value.class.name },
413
+ partitioning: DEPLOYMENT_STABLE_KEYS.include?(key.to_s) ? false : nil
372
414
  )
373
415
  end
374
416
  end
@@ -421,7 +463,8 @@ module Upkeep
421
463
  table: key.fetch(:table),
422
464
  id: key.fetch(:id),
423
465
  attribute: key.fetch(:attribute),
424
- model: metadata[:model]
466
+ model: metadata[:model],
467
+ scope: key[:scope]
425
468
  )
426
469
  when :active_record_collection, :active_record_query
427
470
  dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
@@ -488,6 +531,13 @@ module Upkeep
488
531
  !nil_identity?(dependency)
489
532
  end
490
533
 
534
+ def deployment_stable_request?(dependency)
535
+ return false unless dependency.identity?
536
+ return false unless dependency.source.to_s == "request"
537
+
538
+ RequestValue::DEPLOYMENT_STABLE_KEYS.include?(dependency.key.fetch(:key).to_s)
539
+ end
540
+
491
541
  def identity_absent_for?(dependency, name)
492
542
  absent_by_name = metadata_value(dependency, :identity_absent_by_name) || {}
493
543
  absent_by_name = absent_by_name.transform_keys(&:to_s) if absent_by_name.respond_to?(:transform_keys)
@@ -14,6 +14,7 @@ module Upkeep
14
14
  :frame_id,
15
15
  :identity_signature,
16
16
  :sharing_signature,
17
+ :deployment_signature,
17
18
  :recipe,
18
19
  :matched_dependency_keys,
19
20
  :action,
@@ -32,7 +33,7 @@ module Upkeep
32
33
  end
33
34
  end
34
35
 
35
- Plan = Data.define(:targets, :candidate_entries, :matched_entries) do
36
+ Plan = Data.define(:targets, :candidate_entries, :matched_entries, :request_id) do
36
37
  def summary
37
38
  {
38
39
  targets: targets.size,
@@ -74,7 +75,7 @@ module Upkeep
74
75
  )
75
76
  end
76
77
 
77
- plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries)
78
+ plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries, request_id_for(changes))
78
79
  payload.merge!(payload_for(plan))
79
80
  plan
80
81
  end
@@ -86,6 +87,12 @@ module Upkeep
86
87
 
87
88
  attr_reader :store
88
89
 
90
+ # All changes in a set were captured during one request, so the first stamped
91
+ # request id speaks for the whole plan. Writes from jobs/console carry none.
92
+ def request_id_for(changes)
93
+ changes.filter_map { |change| change[:request_id] if change.respond_to?(:[]) }.first
94
+ end
95
+
89
96
  def payload_for(plan)
90
97
  {
91
98
  candidate_entries: plan.candidate_entries.size,
@@ -152,7 +159,8 @@ module Upkeep
152
159
  shared_stream_target = target
153
160
  identity_signature = subscription.identity_signature(frame_id)
154
161
  sharing_signature = SharedStreams.signature_for(recipe) if shared_delivery && identity_signature == "public" && frame.payload.fetch(:kind) == "render_site"
155
- action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature: sharing_signature)
162
+ deployment_signature = SharedStreams.deployment_signature_for(subscription.graph, frame.id) if sharing_signature
163
+ action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(subscription.graph, frame, recipe, entries, changes, sharing_signature: sharing_signature)
156
164
  target = delivery_target || target
157
165
  subscriber_ids = represented_subscriber_ids(subscription, entries)
158
166
 
@@ -165,6 +173,7 @@ module Upkeep
165
173
  frame_id,
166
174
  identity_signature,
167
175
  sharing_signature,
176
+ deployment_signature,
168
177
  recipe,
169
178
  dependency_keys,
170
179
  action,
@@ -179,12 +188,12 @@ module Upkeep
179
188
  subscriber_ids
180
189
  end
181
190
 
182
- def cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature:)
191
+ def cached_delivery_strategy(graph, frame, recipe, entries, changes, sharing_signature:)
183
192
  key = delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
184
- return delivery_strategy(frame, recipe, entries, changes) unless key
193
+ return delivery_strategy(graph, frame, recipe, entries, changes) unless key
185
194
 
186
195
  @delivery_strategy_cache.fetch(key) do
187
- @delivery_strategy_cache[key] = delivery_strategy(frame, recipe, entries, changes)
196
+ @delivery_strategy_cache[key] = delivery_strategy(graph, frame, recipe, entries, changes)
188
197
  end
189
198
  end
190
199
 
@@ -211,7 +220,7 @@ module Upkeep
211
220
  end
212
221
  end
213
222
 
214
- def delivery_strategy(frame, recipe, entries, changes)
223
+ def delivery_strategy(graph, frame, recipe, entries, changes)
215
224
  remove_recipe = remove_recipe_for(frame, recipe, entries, changes)
216
225
  if remove_recipe
217
226
  return [
@@ -234,7 +243,7 @@ module Upkeep
234
243
  return ["replace", member_replace_recipe, delivery_target, nil]
235
244
  end
236
245
 
237
- [fallback_action_for(frame), recipe, nil, deoptimization_reason(frame, entries, changes)]
246
+ [fallback_action_for(frame), recipe, nil, deoptimization_reason(graph, frame, entries, changes)]
238
247
  end
239
248
 
240
249
  def fallback_action_for(frame)
@@ -292,8 +301,16 @@ module Upkeep
292
301
  CollectionRemove.build(recipe: recipe, change: destroy_changes.first)
293
302
  end
294
303
 
295
- def deoptimization_reason(frame, entries, changes)
296
- return unless frame.payload.fetch(:kind) == "render_site"
304
+ def deoptimization_reason(graph, frame, entries, changes)
305
+ case frame.payload.fetch(:kind)
306
+ when "page"
307
+ page_frame_deoptimization_reason(graph, frame, entries)
308
+ when "render_site"
309
+ render_site_deoptimization_reason(entries, changes)
310
+ end
311
+ end
312
+
313
+ def render_site_deoptimization_reason(entries, changes)
297
314
  return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
298
315
 
299
316
  if changes.one? { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
@@ -307,6 +324,25 @@ module Upkeep
307
324
  end
308
325
  end
309
326
 
327
+ def page_frame_deoptimization_reason(graph, frame, entries)
328
+ return "no_render_site" unless contains_render_site?(graph, frame)
329
+
330
+ "page_frame_dependency:#{entries.map { |entry| dependency_source_label(entry.dependency) }.min}"
331
+ end
332
+
333
+ def contains_render_site?(graph, frame)
334
+ graph.contained_node_ids(frame.id).any? do |node_id|
335
+ node = graph.node(node_id)
336
+ node.kind == :frame && node.payload[:kind] == "render_site" && node.payload[:recipe]
337
+ end
338
+ end
339
+
340
+ def dependency_source_label(dependency)
341
+ return dependency.source.to_s unless dependency.identity?
342
+
343
+ "#{dependency.source}:#{dependency.key.fetch(:key)}"
344
+ end
345
+
310
346
  def destroy_change?(change)
311
347
  type = change.fetch(:type).to_s
312
348
  type.include?("destroy") || type.include?("delete")
@@ -332,6 +368,7 @@ module Upkeep
332
368
  target.target.id,
333
369
  target.identity_signature,
334
370
  target.sharing_signature,
371
+ target.deployment_signature,
335
372
  target.action,
336
373
  target.deoptimization_reason
337
374
  ]
@@ -349,6 +386,7 @@ module Upkeep
349
386
  existing.frame_id,
350
387
  existing.identity_signature,
351
388
  existing.sharing_signature,
389
+ existing.deployment_signature,
352
390
  existing.recipe,
353
391
  (existing.matched_dependency_keys + target.matched_dependency_keys).uniq,
354
392
  existing.action,
@@ -9,6 +9,17 @@ module Upkeep
9
9
  class Channel < ::ActionCable::Channel::Base
10
10
  SUBSCRIBE_NOTIFICATION = "subscribe_channel.upkeep"
11
11
 
12
+ # Liveness heartbeat: a connected page touches its subscription row on
13
+ # this interval, keeping updated_at fresh so opportunistic pruning only
14
+ # ever removes abandoned subscriptions. Invariant: connected =>
15
+ # touched at least every HEARTBEAT_INTERVAL => retained, so this must
16
+ # stay far below config.subscription_ttl (20 minutes vs a 24 hour
17
+ # default). It is a constant because ActionCable fixes periodic timer
18
+ # intervals at class load, before app configuration is readable.
19
+ HEARTBEAT_INTERVAL = 20 * 60
20
+
21
+ periodically :touch_upkeep_subscription, every: HEARTBEAT_INTERVAL
22
+
12
23
  def subscribed
13
24
  if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
14
25
  instrumented_subscribe
@@ -25,6 +36,16 @@ module Upkeep
25
36
 
26
37
  private
27
38
 
39
+ # Cheap liveness touch (update_columns on the subscription row). Must
40
+ # never raise into the cable process; a missed heartbeat just leaves
41
+ # the subscription for a later beat or the opportunistic trim.
42
+ def touch_upkeep_subscription
43
+ Upkeep::Rails.subscriptions.touch(subscription_id)
44
+ nil
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
28
49
  def instrumented_subscribe
29
50
  payload = { subscription_id: safe_subscription_id }
30
51
  ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
@@ -106,13 +127,14 @@ module Upkeep
106
127
  end
107
128
 
108
129
  def log_subscription_rejection(reason)
109
- return unless reason == "missing_activation_token"
110
130
  return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
111
131
 
112
- ::Rails.logger.warn(
113
- "[upkeep] subscription rejected: missing activation_token. " \
114
- "If this started after upgrading upkeep-rails, refresh app/javascript/upkeep/subscription.js from the install generator."
115
- )
132
+ message = +"[upkeep] subscription rejected (#{reason}) subscription_id=#{safe_subscription_id.inspect}."
133
+ if reason == "missing_activation_token"
134
+ message << " If this started after upgrading upkeep-rails, " \
135
+ "refresh app/javascript/upkeep/subscription.js from the install generator."
136
+ end
137
+ ::Rails.logger.warn(message)
116
138
  end
117
139
 
118
140
  def authorized_subscription?(subscription)
@@ -41,8 +41,28 @@ module Upkeep
41
41
 
42
42
  attr_reader :action_cable_connection
43
43
 
44
+ # ActionCable::Connection::Base keeps `request` private on Rails
45
+ # 7.1-8.x but exposes `env` as a public attr_reader; the adapterized
46
+ # connection on Rails main exposes both publicly. Prefer a public
47
+ # `request`, otherwise build one from the public Rack env the same
48
+ # way Action Cable does. Never reach into private connection API.
44
49
  def action_cable_request
45
- action_cable_connection.__send__(:request)
50
+ @action_cable_request ||=
51
+ if action_cable_connection.respond_to?(:request)
52
+ action_cable_connection.request
53
+ elsif action_cable_connection.respond_to?(:env)
54
+ ::ActionDispatch::Request.new(rails_rack_env)
55
+ else
56
+ raise UnidentifiedSubscriber,
57
+ "ActionCable connection exposes neither a public request nor a public env"
58
+ end
59
+ end
60
+
61
+ def rails_rack_env
62
+ env = action_cable_connection.env
63
+ return env unless defined?(::Rails.application) && ::Rails.application
64
+
65
+ ::Rails.application.env_config.merge(env)
46
66
  end
47
67
  end
48
68
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
- require "json"
5
4
 
6
5
  module Upkeep
7
6
  module Rails
@@ -16,20 +15,21 @@ module Upkeep
16
15
  "#{html}#{marker}"
17
16
  end
18
17
 
18
+ # The payload travels as attributes (like turbo-cable-stream-source), never
19
+ # as text content, so it can't show up as page text when JS is absent.
19
20
  def marker_for(identity:, subscription:)
20
- payload = JSON.generate(
21
- channel: CHANNEL,
22
- subscription_id: subscription.id,
23
- activation_token: ActivationToken.generate(subscription),
24
- stream_name: identity.stream_name
25
- ).gsub("</", '<\/')
26
-
27
- id = "upkeep-subscription-source-#{subscription.id}"
21
+ attributes = {
22
+ "id" => "upkeep-subscription-source-#{subscription.id}",
23
+ "channel" => CHANNEL,
24
+ "subscription-id" => subscription.id,
25
+ "activation-token" => ActivationToken.generate(subscription),
26
+ "stream-name" => identity.stream_name
27
+ }
28
28
 
29
29
  [
30
- %(<upkeep-subscription-source id="#{CGI.escapeHTML(id)}" ),
31
- %(data-upkeep-subscription data-turbo-temporary>),
32
- CGI.escapeHTML(payload),
30
+ %(<upkeep-subscription-source ),
31
+ attributes.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" "),
32
+ %( hidden style="display:none" data-upkeep-subscription data-turbo-temporary>),
33
33
  %(</upkeep-subscription-source>)
34
34
  ].join
35
35
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ # Detects boot configurations where live updates silently break across
6
+ # cluster workers: an in-process cable adapter or an in-memory
7
+ # subscription store cannot reach browsers or subscriptions held by
8
+ # another process.
9
+ class ClusterGuard
10
+ IN_PROCESS_CABLE_ADAPTERS = %w[async].freeze
11
+
12
+ attr_reader :cable_adapter, :worker_count, :subscription_store, :environment
13
+
14
+ def initialize(cable_adapter:, worker_count:, subscription_store:, environment:)
15
+ @cable_adapter = cable_adapter.to_s
16
+ @worker_count = worker_count.to_i
17
+ @subscription_store = subscription_store&.to_sym
18
+ @environment = environment.to_s
19
+ end
20
+
21
+ def clustered?
22
+ worker_count.positive?
23
+ end
24
+
25
+ def problems
26
+ return [] unless clustered?
27
+
28
+ problems = []
29
+ if IN_PROCESS_CABLE_ADAPTERS.include?(cable_adapter)
30
+ problems << "the #{cable_adapter} Action Cable adapter is in-process, so broadcasts from one worker " \
31
+ "never reach sockets held by another; configure a cross-process cable adapter such as solid_cable " \
32
+ "or redis in config/cable.yml"
33
+ end
34
+ if subscription_store == :memory
35
+ problems << "subscription_store=:memory is per-process, so subscriptions registered in one worker are " \
36
+ "invisible to the others; set config.upkeep.subscription_store = :active_record"
37
+ end
38
+ problems
39
+ end
40
+
41
+ def error?
42
+ problems.any? && environment == "production"
43
+ end
44
+
45
+ def warning?
46
+ problems.any? && !error?
47
+ end
48
+
49
+ def message
50
+ return if problems.empty?
51
+
52
+ "Upkeep detected a clustered server (#{worker_count} workers) with a configuration that cannot " \
53
+ "deliver live updates across processes: #{problems.join("; ")}."
54
+ end
55
+ end
56
+ end
57
+ end
@@ -8,7 +8,6 @@ module Upkeep
8
8
  SUBSCRIPTION_STORES = [:active_record, :memory].freeze
9
9
  REFUSED_BOUNDARY_BEHAVIORS = [:raise, :warn].freeze
10
10
  IDENTITY_SOURCES = [:current, :session, :cookie, :warden].freeze
11
- DELIVERY_ADAPTERS = [:async, :active_job, :inline].freeze
12
11
 
13
12
  class IdentityDefinition
14
13
  attr_reader :name, :source, :source_key, :subscribe_block
@@ -99,18 +98,23 @@ module Upkeep
99
98
  attr_accessor :enabled
100
99
  attr_accessor :activation_token_expires_in
101
100
  attr_accessor :delivery_batch_window
102
- attr_accessor :delivery_queue
101
+ # Seconds a subscription may go untouched before it counts as abandoned
102
+ # and becomes eligible for pruning. Connected pages are kept alive by the
103
+ # cable channel heartbeat, which fires far more often than this TTL.
104
+ attr_accessor :subscription_ttl
105
+ # Test/console hook: deliver changes synchronously in the caller instead
106
+ # of on the in-process background dispatcher.
107
+ attr_accessor :deliver_inline
103
108
  attr_reader :subscription_store
104
- attr_reader :delivery_adapter
105
109
 
106
110
  def initialize
107
111
  @enabled = true
108
112
  @subscription_store = :active_record
109
- @delivery_adapter = :async
110
- @delivery_queue = :upkeep_realtime
113
+ @deliver_inline = false
111
114
  @delivery_batch_window = 0.01
112
115
  @refused_boundary_behavior = nil
113
116
  @activation_token_expires_in = 24 * 60 * 60
117
+ @subscription_ttl = 24 * 60 * 60
114
118
  @identity_definitions = {}
115
119
  end
116
120
 
@@ -125,17 +129,6 @@ module Upkeep
125
129
  @subscription_store = value
126
130
  end
127
131
 
128
- def delivery_adapter=(value)
129
- value = value.to_sym if value.respond_to?(:to_sym)
130
-
131
- unless DELIVERY_ADAPTERS.include?(value)
132
- raise ConfigurationError,
133
- "Unknown Upkeep delivery_adapter #{value.inspect}; expected one of #{DELIVERY_ADAPTERS.join(", ")}"
134
- end
135
-
136
- @delivery_adapter = value
137
- end
138
-
139
132
  def refused_boundary_behavior
140
133
  @refused_boundary_behavior || default_refused_boundary_behavior
141
134
  end
@@ -87,6 +87,7 @@ module Upkeep
87
87
  end
88
88
  end
89
89
  end
90
+ changes = upkeep_stamp_change_request_id(changes)
90
91
  record_capture_payload(payload, capture) if capture
91
92
 
92
93
  measure_phase(payload, :deliver_changes_ms) do
@@ -118,6 +119,22 @@ module Upkeep
118
119
  result
119
120
  end
120
121
 
122
+ # Changes committed while handling this request carry the originating Turbo
123
+ # request id, so the client that caused them ignores its own refresh delivery
124
+ # (Turbo's recentRequests debounce). Breaks self-refresh loops from writes
125
+ # during GETs, e.g. view tracking.
126
+ def upkeep_stamp_change_request_id(changes)
127
+ request_id = upkeep_turbo_request_id
128
+ return changes unless request_id
129
+
130
+ changes.map { |change| change.respond_to?(:merge) ? change.merge(request_id: request_id) : change }
131
+ end
132
+
133
+ def upkeep_turbo_request_id
134
+ turbo_request_id = ::Turbo.current_request_id if defined?(::Turbo) && ::Turbo.respond_to?(:current_request_id)
135
+ turbo_request_id || request.headers["X-Turbo-Request-Id"]
136
+ end
137
+
121
138
  def record_capture_payload(payload, capture)
122
139
  payload[:response_status] = capture.response_status
123
140
  payload[:response_content_type] = capture.response_content_type
@@ -9,8 +9,7 @@ module Upkeep
9
9
  Upkeep::Rails.configure do |config|
10
10
  config.enabled = app.config.upkeep.fetch(:enabled, true)
11
11
  config.subscription_store = app.config.upkeep.fetch(:subscription_store, config.subscription_store)
12
- config.delivery_adapter = app.config.upkeep.fetch(:delivery_adapter, Railtie.default_delivery_adapter(app))
13
- config.delivery_queue = app.config.upkeep.fetch(:delivery_queue, config.delivery_queue)
12
+ config.deliver_inline = app.config.upkeep.fetch(:deliver_inline, config.deliver_inline)
14
13
  config.delivery_batch_window =
15
14
  app.config.upkeep.fetch(:delivery_batch_window, config.delivery_batch_window)
16
15
  config.activation_token_expires_in =
@@ -37,14 +36,6 @@ module Upkeep
37
36
  ::Rake.respond_to?(:application) &&
38
37
  ::Rake.application.top_level_tasks.any?
39
38
  end
40
-
41
- def self.default_delivery_adapter(app)
42
- if app.respond_to?(:env) && app.env.to_s == "production"
43
- :active_job
44
- else
45
- Upkeep::Rails.configuration.delivery_adapter
46
- end
47
- end
48
39
  end
49
40
  end
50
41
  end
@@ -14,7 +14,7 @@ module Upkeep
14
14
  # broadcast assertions.
15
15
  #
16
16
  # Production code should not call this; normal app delivery runs
17
- # through the configured adapter.
17
+ # on the in-process dispatcher.
18
18
  #
19
19
  # @return [void]
20
20
  def drain_delivery!