upkeep-rails 0.1.6

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,97 @@
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
+ class << self
9
+ # Drains the async delivery dispatcher when a test needs deterministic
10
+ # broadcast assertions.
11
+ #
12
+ # Production code should not call this; normal app delivery runs
13
+ # through the configured adapter.
14
+ #
15
+ # @return [void]
16
+ def drain_delivery!
17
+ Upkeep::Rails.send(:drain_delivery_dispatcher!)
18
+ end
19
+ end
20
+
21
+ # Asserts that the last successful HTML response injected an Upkeep
22
+ # subscription marker and registered a subscription in the configured
23
+ # store.
24
+ #
25
+ # @param message [String, nil] optional assertion failure message.
26
+ # @return [void]
27
+ def assert_upkeep_subscription_registered(message = nil)
28
+ assert_select "upkeep-subscription-source[data-upkeep-subscription]"
29
+ assert Upkeep::Rails.subscriptions.subscriptions.any?,
30
+ message || "expected Upkeep to register at least one subscription"
31
+ end
32
+
33
+ # Returns the most recently registered Upkeep subscription.
34
+ #
35
+ # @return [Upkeep::Subscriptions::Subscription, nil]
36
+ def upkeep_subscription
37
+ Upkeep::Rails.subscriptions.subscriptions.last
38
+ end
39
+
40
+ # Returns every ActionCable stream name that can receive broadcasts for a
41
+ # subscription, including shared streams.
42
+ #
43
+ # @param subscription [Upkeep::Subscriptions::Subscription]
44
+ # @return [Array<String>]
45
+ # @raise [ArgumentError] when no subscription is registered.
46
+ def upkeep_stream_names(subscription = upkeep_subscription)
47
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
48
+
49
+ ([subscription.metadata.fetch(:stream_name)] + subscription.metadata.fetch(:shared_stream_names, [])).uniq
50
+ end
51
+
52
+ # Activates the registered subscription so delivery lookup can find it.
53
+ #
54
+ # @param subscription [Upkeep::Subscriptions::Subscription]
55
+ # @return [Upkeep::Subscriptions::Subscription]
56
+ # @raise [ArgumentError] when no subscription is registered.
57
+ # @raise [Upkeep::Subscriptions::NotFound] when activation fails.
58
+ def activate_upkeep_subscription!(subscription = upkeep_subscription)
59
+ raise ArgumentError, "no Upkeep subscription is registered" unless subscription
60
+
61
+ activated = Upkeep::Rails.subscriptions.activate(subscription.id)
62
+ raise Upkeep::Subscriptions::NotFound, subscription.id unless activated
63
+
64
+ subscription
65
+ end
66
+
67
+ # Captures ActionCable broadcasts for every stream associated with a
68
+ # subscription while the block runs.
69
+ #
70
+ # Include ActionCable::TestHelper before calling this helper.
71
+ #
72
+ # @param subscription [Upkeep::Subscriptions::Subscription]
73
+ # @return [Array<String>]
74
+ # @raise [ArgumentError] when called without a block or subscription.
75
+ # @raise [NoMethodError] when ActionCable::TestHelper is not included.
76
+ def capture_upkeep_broadcasts(subscription = upkeep_subscription, &block)
77
+ raise ArgumentError, "capture_upkeep_broadcasts requires a block" unless block
78
+ raise NoMethodError, "include ActionCable::TestHelper before calling capture_upkeep_broadcasts" unless respond_to?(:capture_broadcasts)
79
+
80
+ captures = {}
81
+ nested = upkeep_stream_names(subscription).reverse_each.reduce(block) do |inner, stream_name|
82
+ proc { captures[stream_name] = capture_broadcasts(stream_name, &inner) }
83
+ end
84
+
85
+ nested.call
86
+ captures.values.flatten
87
+ end
88
+
89
+ # Drains async Upkeep delivery for deterministic test assertions.
90
+ #
91
+ # @return [void]
92
+ def drain_upkeep_delivery!
93
+ Upkeep::Rails::Testing.drain_delivery!
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,349 @@
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
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
114
+
115
+ dispatch_changes(changes)
116
+ rescue StandardError => error
117
+ instrument_delivery_enqueue_error(changes, error)
118
+ Delivery::Transport::DispatchReport.new([])
119
+ end
120
+
121
+ # Delivers committed application changes immediately in the current
122
+ # process.
123
+ #
124
+ # This is used by the inline delivery adapter and Active Job worker. Tests
125
+ # that need deterministic async delivery should use
126
+ # Upkeep::Rails::Testing instead of calling this directly.
127
+ #
128
+ # @param changes [Array<#to_h>] change events to deliver. Defaults to the
129
+ # current runtime change log.
130
+ # @return [Upkeep::Delivery::TurboStreams::Batch, Upkeep::Delivery::Transport::DispatchReport]
131
+ def deliver_changes_now!(changes = Runtime::ChangeLog.drain)
132
+ changes = deliverable_changes(changes)
133
+ return Delivery::Transport::DispatchReport.new([]) if changes.empty?
134
+
135
+ batch = delivery_batch_for([changes])
136
+ transport.deliver(batch)
137
+ end
138
+
139
+ def validate_configuration!(environment: rails_environment)
140
+ return true unless configuration.enabled
141
+
142
+ validate_subscription_store!(environment: environment)
143
+ true
144
+ end
145
+
146
+ private
147
+
148
+ def delivery_dispatcher
149
+ @delivery_dispatcher ||= Delivery::AsyncDispatcher.new(batch_window: configuration.delivery_batch_window) do |change_sets|
150
+ batch = delivery_batch_for(change_sets)
151
+ transport.deliver(batch)
152
+ end
153
+ end
154
+
155
+ def drain_delivery_dispatcher!
156
+ @delivery_dispatcher&.drain
157
+ end
158
+
159
+ def dispatch_changes(changes)
160
+ payload = {
161
+ adapter: configuration.delivery_adapter,
162
+ queue: configuration.delivery_queue,
163
+ change_count: changes.size
164
+ }
165
+
166
+ ActiveSupport::Notifications.instrument(DELIVERY_ENQUEUE, payload) do
167
+ case configuration.delivery_adapter
168
+ when :active_job
169
+ DeliveryJob.perform_later(changes)
170
+ Delivery::Transport::DispatchReport.new([])
171
+ when :async
172
+ delivery_dispatcher.enqueue(changes)
173
+ when :inline
174
+ deliver_changes_now!(changes)
175
+ end
176
+ end
177
+ end
178
+
179
+ def instrument_delivery_enqueue_error(changes, error)
180
+ ActiveSupport::Notifications.instrument(
181
+ DELIVERY_ENQUEUE_ERROR,
182
+ adapter: configuration.delivery_adapter,
183
+ queue: configuration.delivery_queue,
184
+ change_count: changes.size,
185
+ error_class: error.class.name,
186
+ error_message: error.message
187
+ )
188
+ end
189
+
190
+ def subscription_registrar
191
+ @subscription_registrar ||= Subscriptions::Registrar.new(
192
+ store: subscriptions,
193
+ shape_cache: subscription_shape_cache
194
+ )
195
+ end
196
+
197
+ def subscription_shape_cache
198
+ @subscription_shape_cache ||= Subscriptions::ShapeCache.new
199
+ end
200
+
201
+ def delivery_batch_for(change_sets)
202
+ change_sets = compact_change_sets(change_sets)
203
+ return Delivery::TurboStreams::Batch.new([]) if change_sets.empty?
204
+
205
+ planner = Invalidation::Planner.new(store: subscriptions)
206
+ plans = change_sets.map { |changes| planner.plan(changes) }
207
+ plans = plans.reject { |plan| plan.targets.empty? }
208
+ return Delivery::TurboStreams::Batch.new([]) if plans.empty?
209
+
210
+ Delivery::TurboStreams.new.build_many(plans)
211
+ end
212
+
213
+ def compact_change_sets(change_sets)
214
+ change_sets
215
+ .map { |changes| deliverable_changes(changes) }
216
+ .reject(&:empty?)
217
+ .uniq { |changes| change_set_key(changes) }
218
+ end
219
+
220
+ def deliverable_changes(changes)
221
+ Array(changes).reject { |change| internal_delivery_change?(change) }
222
+ end
223
+
224
+ def internal_delivery_change?(change)
225
+ INTERNAL_DELIVERY_TABLES.include?(change_value(change, :table).to_s)
226
+ end
227
+
228
+ def change_set_key(changes)
229
+ changes.map { |change| change_key(change) }.sort
230
+ end
231
+
232
+ def change_key(change)
233
+ [
234
+ change_value(change, :type).to_s,
235
+ change_value(change, :table).to_s,
236
+ change_value(change, :model).to_s,
237
+ change_value(change, :id).to_s,
238
+ Array(change_value(change, :changed_attributes)).map(&:to_s).sort,
239
+ change_value(change, :old_values).inspect,
240
+ change_value(change, :new_values).inspect
241
+ ]
242
+ end
243
+
244
+ def change_value(change, key)
245
+ return unless change.respond_to?(:[])
246
+
247
+ change[key] || change[key.to_s]
248
+ end
249
+
250
+ def discard_subscription_store!
251
+ @subscriptions&.shutdown
252
+ @subscriptions = nil
253
+ @subscription_registrar = nil
254
+ end
255
+
256
+ def identity_metadata(decision)
257
+ {
258
+ identity_mode: decision.mode,
259
+ anonymous: decision.anonymous,
260
+ anonymous_deopt_reason: decision.deopt_reason,
261
+ identity_sources: decision.identity_sources,
262
+ identity_names: decision.identity_names
263
+ }.compact
264
+ end
265
+
266
+ def instrument_subscription_identity(decision, registered:, subscription: nil, deopt_reason: nil, **extra)
267
+ ActiveSupport::Notifications.instrument(
268
+ SUBSCRIPTION_IDENTITY,
269
+ {
270
+ registered: registered,
271
+ subscription_id: subscription&.id,
272
+ subscriber_id: subscription&.subscriber_id,
273
+ identity_mode: decision&.mode,
274
+ anonymous: decision&.anonymous,
275
+ anonymous_deopt_reason: deopt_reason || decision&.deopt_reason,
276
+ identity_sources: decision&.identity_sources,
277
+ identity_names: decision&.identity_names
278
+ }.merge(extra)
279
+ )
280
+ end
281
+
282
+ def subscription_response?(controller, capture)
283
+ controller.request.get? &&
284
+ capture.successful? &&
285
+ capture.html_response? &&
286
+ capture.html.include?("</") &&
287
+ capture.recorder.graph.frame_nodes.any?
288
+ end
289
+
290
+ def build_subscription_store
291
+ case configuration.subscription_store
292
+ when :active_record
293
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
294
+ unless schema_errors.empty?
295
+ raise ConfigurationError,
296
+ active_record_subscription_store_error(schema_errors)
297
+ end
298
+
299
+ Subscriptions::ActiveRecordStore.new
300
+ when :memory
301
+ Subscriptions::Store.new
302
+ end.tap do
303
+ @subscription_store_name = configuration.subscription_store
304
+ end
305
+ end
306
+
307
+ def validate_subscription_store!(environment:)
308
+ if production_environment?(environment) && configuration.subscription_store == :memory
309
+ raise ConfigurationError,
310
+ "Upkeep subscription_store=:memory is only for development/test; production requires :active_record."
311
+ end
312
+
313
+ return true unless production_environment?(environment)
314
+ return true unless configuration.subscription_store == :active_record
315
+ schema_errors = Subscriptions::ActiveRecordStore.schema_errors(connect: true)
316
+ return true if schema_errors.empty?
317
+
318
+ raise ConfigurationError, active_record_subscription_store_error(schema_errors, production: true)
319
+ end
320
+
321
+ def active_record_subscription_store_error(schema_errors, production: false)
322
+ prefix = if production
323
+ "Upkeep production boot requires compatible upkeep_subscriptions, " \
324
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables " \
325
+ "for subscription_store=:active_record."
326
+ else
327
+ "Upkeep subscription_store=:active_record requires compatible upkeep_subscriptions, " \
328
+ "upkeep_subscription_index_entries, and upkeep_subscription_shape_index_entries tables."
329
+ end
330
+
331
+ "#{prefix} Schema errors: #{schema_errors.join("; ")}. Run bin/rails generate upkeep:install " \
332
+ "and bin/rails db:migrate, rebuild stale development/test databases, or set " \
333
+ "config.upkeep.subscription_store = :memory in development/test."
334
+ end
335
+
336
+ def production_environment?(environment)
337
+ environment.to_s == "production"
338
+ end
339
+
340
+ def rails_environment
341
+ ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
342
+ end
343
+
344
+ def subscription_store_config_changed?
345
+ @subscription_store_name != configuration.subscription_store
346
+ end
347
+ end
348
+ end
349
+ end