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,920 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "action_view/renderer/collection_renderer"
5
+ require "active_support/notifications"
6
+ require "cgi"
7
+ require "digest"
8
+ require "stringio"
9
+ require_relative "../active_record_query"
10
+ require_relative "../herb/manifest_cache"
11
+ require_relative "../herb/source_instrumenter"
12
+
13
+ module Upkeep
14
+ module Rails
15
+ module ActionViewCapture
16
+ module_function
17
+
18
+ FRAME_STACK_KEY = :upkeep_rails_frame_stack
19
+ RENDER_SITE_STACK_KEY = :upkeep_rails_render_site_stack
20
+
21
+ MANIFEST_PARSE_OPTIONS = HerbSupport::TemplateManifest::DEFAULT_PARSE_OPTIONS.merge(
22
+ transform_conditionals: false
23
+ ).freeze
24
+
25
+ REPLAY_HTTP_ENV_KEYS = %w[
26
+ HTTP_ACCEPT
27
+ HTTP_HOST
28
+ HTTP_X_FORWARDED_HOST
29
+ HTTP_X_FORWARDED_PROTO
30
+ ].freeze
31
+
32
+ REQUEST_REPLAY_ENV_KEYS = {
33
+ "host" => "HTTP_HOST",
34
+ "request_method" => "REQUEST_METHOD",
35
+ "user_agent" => "HTTP_USER_AGENT",
36
+ "remote_ip" => "REMOTE_ADDR"
37
+ }.freeze
38
+
39
+ RefusedCollection = Data.define(:reason, :message, :suggestions, :error)
40
+
41
+ def install
42
+ return if @installed
43
+
44
+ ::ActionView::Template.prepend(TemplateHook)
45
+ ::ActionView::CollectionRenderer.prepend(CollectionRendererHook)
46
+ ::ActionView::Base.include(ViewHelpers)
47
+
48
+ @installed = true
49
+ end
50
+
51
+ def installed?
52
+ !!@installed
53
+ end
54
+
55
+ def capture_template(template, view, locals, implicit_locals:, add_to_stack:, block:)
56
+ instrument_template_source!(template)
57
+ captured_locals = locals.dup
58
+ metadata = template_metadata(template, captured_locals)
59
+ controller = controller_for_view(view)
60
+ page_controller = controller if metadata.fetch(:kind) == "page"
61
+ metadata = metadata.merge(controller: controller_metadata(page_controller)) if page_controller
62
+ frame_id = frame_id_for_template(metadata, captured_locals)
63
+ recipe = if page_controller
64
+ controller_page_recipe(frame_id: frame_id, controller: page_controller, metadata: metadata)
65
+ else
66
+ template_recipe(
67
+ frame_id: frame_id,
68
+ template: template,
69
+ view: view,
70
+ controller: controller,
71
+ locals: captured_locals,
72
+ metadata: metadata,
73
+ implicit_locals: implicit_locals,
74
+ add_to_stack: add_to_stack,
75
+ block: block
76
+ )
77
+ end
78
+
79
+ Runtime::Observation.capture_frame(frame_id, metadata.merge(recipe: recipe)) do
80
+ with_frame_id(frame_id) { yield }
81
+ end
82
+ end
83
+
84
+ def capture_collection(partial, collection, rendered_collection, context, options, block, collection_analysis: nil)
85
+ # No active capture (e.g. a request that opted out via upkeep_reactive_request?):
86
+ # render the collection normally without building dependencies or replay
87
+ # snapshots, which would otherwise analyze and refuse an opaque relation on a
88
+ # page that is deliberately not reactive.
89
+ return yield unless Runtime::Observation.recording?
90
+
91
+ render_site = current_render_site
92
+ unless render_site
93
+ record_collection_dependency(collection, collection_analysis: collection_analysis)
94
+ return yield
95
+ end
96
+
97
+ captured_options = render_options_for_replay(options)
98
+ metadata = collection_metadata(partial, collection, render_site: render_site)
99
+ frame_id = "site:#{metadata.fetch(:site_id)}"
100
+ recipe = collection_recipe(
101
+ frame_id: frame_id,
102
+ partial: partial,
103
+ collection: collection,
104
+ rendered_collection: rendered_collection,
105
+ context: context,
106
+ controller: controller_for_view(context),
107
+ options: captured_options,
108
+ metadata: metadata,
109
+ block: block,
110
+ collection_analysis: collection_analysis
111
+ )
112
+
113
+ Runtime::Observation.capture_frame(frame_id, metadata.merge(recipe: recipe)) do
114
+ record_collection_dependency(collection, collection_analysis: collection_analysis)
115
+ yield
116
+ end
117
+ end
118
+
119
+ def collection_analysis(collection)
120
+ # Only analyze rendered collections during an active capture. Outside a capture
121
+ # (e.g. a request that opted out via upkeep_reactive_request?) there is no
122
+ # dependency to build, and analyzing would refuse/raise on an opaque relation
123
+ # that the page deliberately keeps non-reactive.
124
+ return unless Runtime::Observation.recording?
125
+
126
+ provenance = Runtime::Observation.relation_provenance_for(collection)
127
+ return provenance if provenance
128
+ return unless active_record_relation?(collection)
129
+
130
+ ActiveRecordQuery.analyze(collection)
131
+ rescue ActiveRecordQuery::OpaqueRelationError => error
132
+ handle_refused_collection(error)
133
+ end
134
+
135
+ def collection_capture_pair(collection)
136
+ if active_record_relation?(collection)
137
+ rendered_collection = Runtime::RelationObserver.suppress_dependency_tracking { collection.to_a }
138
+ [collection, rendered_collection]
139
+ else
140
+ [collection, collection]
141
+ end
142
+ end
143
+
144
+ def template_metadata(template, locals)
145
+ template_static_metadata(template).merge(locals: local_metadata(locals))
146
+ end
147
+
148
+ def collection_metadata(partial, collection, render_site: nil)
149
+ collection_key = collection_key(collection)
150
+ site_id = render_site.fetch(:site_id)
151
+
152
+ {
153
+ kind: "render_site",
154
+ site_id: site_id,
155
+ partial: partial.to_s,
156
+ collection: collection_key
157
+ }.merge(manifest_metadata(render_site))
158
+ end
159
+
160
+ def template_recipe(frame_id:, template:, view:, controller:, locals:, metadata:, implicit_locals:, add_to_stack:, block:)
161
+ target_kind = metadata.fetch(:kind) == "fragment" ? "fragment" : "page"
162
+ assigns = replayable_view_assigns(view, controller: controller)
163
+ ::Upkeep::Replay::Recipe.new(
164
+ kind: metadata.fetch(:kind).to_sym,
165
+ frame_id: frame_id,
166
+ target_kind: target_kind,
167
+ target_id: frame_id,
168
+ template: metadata.fetch(:template),
169
+ metadata: metadata,
170
+ runtime: "rails",
171
+ replay: (target_kind == "fragment" ? ::Upkeep::Replay::Fragment : ::Upkeep::Replay::Template).new(
172
+ controller_class: controller&.class&.name,
173
+ template: metadata.fetch(:template),
174
+ locals: snapshot_hash(locals),
175
+ assigns: snapshot_hash(assigns)
176
+ )
177
+ ) do
178
+ with_replayed_view_assigns(view, assigns) do
179
+ template.render(
180
+ view,
181
+ replay_locals(locals),
182
+ nil,
183
+ implicit_locals: implicit_locals,
184
+ add_to_stack: add_to_stack,
185
+ &block
186
+ )
187
+ end
188
+ end
189
+ end
190
+
191
+ def controller_page_recipe(frame_id:, controller:, metadata:)
192
+ controller_class = controller.class
193
+ action_name = controller.action_name
194
+ ambient_inputs = request_ambient_replay_inputs
195
+ env = replay_env(
196
+ controller.request.env,
197
+ path_parameters: controller.request.path_parameters,
198
+ ambient_inputs: ambient_inputs
199
+ )
200
+
201
+ ::Upkeep::Replay::Recipe.new(
202
+ kind: :page,
203
+ frame_id: frame_id,
204
+ target_kind: "page",
205
+ target_id: frame_id,
206
+ template: metadata.fetch(:template),
207
+ metadata: metadata,
208
+ runtime: "rails",
209
+ replay: ::Upkeep::Replay::ControllerPage.new(
210
+ controller_class: controller_class.name,
211
+ action: action_name,
212
+ env: serializable_replay_env(
213
+ controller.request.env,
214
+ path_parameters: controller.request.path_parameters,
215
+ ambient_inputs: ambient_inputs
216
+ )
217
+ )
218
+ ) do
219
+ _status, _headers, body = ControllerRuntime.suppress do
220
+ controller_class.action(action_name).call(Replay.rack_env(env))
221
+ end
222
+ collect_response_body(body)
223
+ end
224
+ end
225
+
226
+ def collection_recipe(frame_id:, partial:, collection:, rendered_collection:, context:, controller:, options:, metadata:, block:, collection_analysis: nil)
227
+ ::Upkeep::Replay::Recipe.new(
228
+ kind: :render_site,
229
+ frame_id: frame_id,
230
+ target_kind: "render_site",
231
+ target_id: metadata.fetch(:site_id),
232
+ metadata: metadata,
233
+ runtime: "rails",
234
+ replay: ::Upkeep::Replay::Collection.new(
235
+ controller_class: controller&.class&.name,
236
+ partial: partial == :derived ? "derived" : partial.to_s,
237
+ collection: snapshot_value(collection, rendered_collection: rendered_collection, relation_analysis: collection_analysis),
238
+ options: snapshot_render_options(options)
239
+ )
240
+ ) do
241
+ replay_collection = replay_collection_value(collection, collection_analysis)
242
+
243
+ if partial == :derived
244
+ context.render(replay_collection, &block)
245
+ else
246
+ replay_options = replay_render_options(options)
247
+ replay_options[:partial] = partial
248
+ replay_options[:collection] = replay_collection
249
+ context.render(replay_options, &block)
250
+ end
251
+ end
252
+ end
253
+
254
+ def controller_for_view(view)
255
+ return unless view.respond_to?(:controller)
256
+
257
+ controller = view.controller
258
+ return unless controller&.respond_to?(:request) && controller.respond_to?(:action_name)
259
+
260
+ controller
261
+ end
262
+
263
+ def controller_metadata(controller)
264
+ request = controller.request
265
+ {
266
+ class: controller.class.name,
267
+ action: controller.action_name,
268
+ request_method: request.env["REQUEST_METHOD"].to_s,
269
+ path: request.env["PATH_INFO"].to_s,
270
+ query_string_digest: Digest::SHA256.hexdigest(request.env["QUERY_STRING"].to_s)[0, 16],
271
+ path_parameters: request.path_parameters.keys.map(&:to_s).sort
272
+ }
273
+ end
274
+
275
+ def serializable_replay_env(env, path_parameters: nil, ambient_inputs: {})
276
+ replay_env(env, path_parameters: path_parameters, ambient_inputs: ambient_inputs).reject do |key, _value|
277
+ key == "rack.input" || key == "rack.errors"
278
+ end
279
+ end
280
+
281
+ def replay_env(env, path_parameters: nil, ambient_inputs: {})
282
+ copy = env.each_with_object({}) do |(key, value), replay|
283
+ replay[key] = replay_env_value(value) if replay_env_key?(key)
284
+ end
285
+
286
+ session_snapshot = session_replay_snapshot(
287
+ env["rack.session"],
288
+ observed_values: ambient_inputs.fetch(:session, {})
289
+ )
290
+ cookie_header = cookie_replay_header(ambient_inputs.fetch(:cookie, {}))
291
+ copy["rack.session"] = session_snapshot if session_snapshot
292
+ copy["HTTP_COOKIE"] = cookie_header if cookie_header
293
+ request_replay_env(ambient_inputs.fetch(:request, {})).each do |key, value|
294
+ copy[key] = value
295
+ end
296
+ copy["rack.input"] = StringIO.new
297
+ copy["rack.errors"] ||= StringIO.new
298
+ copy["action_dispatch.request.path_parameters"] = path_parameters if path_parameters
299
+ copy
300
+ end
301
+
302
+ def replay_env_key?(key)
303
+ return false if key == "HTTP_COOKIE"
304
+
305
+ REPLAY_HTTP_ENV_KEYS.include?(key) ||
306
+ key.start_with?("REQUEST_") ||
307
+ key.start_with?("SERVER_") ||
308
+ key.start_with?("REMOTE_") ||
309
+ key == "rack.url_scheme" ||
310
+ %w[
311
+ CONTENT_LENGTH
312
+ CONTENT_TYPE
313
+ HTTPS
314
+ PATH_INFO
315
+ QUERY_STRING
316
+ SCRIPT_NAME
317
+ action_dispatch.request.path_parameters
318
+ ].include?(key)
319
+ end
320
+
321
+ def replay_env_value(value)
322
+ case value
323
+ when Hash
324
+ value.transform_values { |nested_value| replay_env_scalar_value(nested_value) }
325
+ when Array
326
+ value.map { |nested_value| replay_env_scalar_value(nested_value) }
327
+ else
328
+ replay_env_scalar_value(value)
329
+ end
330
+ end
331
+
332
+ def replay_env_scalar_value(value)
333
+ case value
334
+ when Hash
335
+ value.transform_values { |nested_value| replay_env_scalar_value(nested_value) }
336
+ when Array
337
+ value.map { |nested_value| replay_env_scalar_value(nested_value) }
338
+ else
339
+ value
340
+ end
341
+ end
342
+
343
+ def request_ambient_replay_inputs
344
+ Runtime::Observation.recorder&.ambient_replay_inputs_for(Runtime::Recorder::REQUEST_NODE_ID) || {}
345
+ end
346
+
347
+ def session_replay_snapshot(session, observed_values:)
348
+ values = observed_values.transform_keys(&:to_s)
349
+ return if values.empty?
350
+
351
+ session_id = session_id_for_replay(session)
352
+ values = values.merge("session_id" => session_id.to_s) if session_id && !session_id.to_s.empty?
353
+
354
+ {
355
+ "__upkeep_replay_type" => "rack_session",
356
+ "values" => replay_env_scalar_value(values)
357
+ }
358
+ end
359
+
360
+ def session_id_for_replay(session)
361
+ session.id if session.respond_to?(:id)
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ def cookie_replay_header(observed_values)
367
+ values = observed_values.transform_keys(&:to_s).reject { |_key, value| value.nil? }
368
+ return if values.empty?
369
+
370
+ values.map do |key, value|
371
+ "#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"
372
+ end.join("; ")
373
+ end
374
+
375
+ def request_replay_env(observed_values)
376
+ observed_values.transform_keys(&:to_s).each_with_object({}) do |(key, value), replay_env|
377
+ env_key = REQUEST_REPLAY_ENV_KEYS[key]
378
+ replay_env[env_key] = replay_env_scalar_value(value) if env_key && !value.nil?
379
+ end
380
+ end
381
+
382
+ def collect_response_body(body)
383
+ body.each.to_a.join
384
+ ensure
385
+ body.close if body.respond_to?(:close)
386
+ end
387
+
388
+ def instrument_template_source!(template)
389
+ return if template.instance_variable_get(:@upkeep_herb_instrumented)
390
+ return unless erb_template?(template)
391
+
392
+ manifest = manifest_for_template(template)
393
+ instrumented_source = HerbSupport::SourceInstrumenter.new(manifest: manifest).instrument(template.source)
394
+ template.instance_variable_set(:@upkeep_herb_original_source, template.source)
395
+ template.instance_variable_set(:@source, instrumented_source)
396
+ template.instance_variable_set(:@upkeep_herb_instrumented, true)
397
+ end
398
+
399
+ def erb_template?(template)
400
+ template.identifier.to_s.end_with?(".erb") || template.respond_to?(:handler) && template.handler.class.name.include?("ERB")
401
+ end
402
+
403
+ def manifest_for_template(template)
404
+ template.instance_variable_get(:@upkeep_herb_manifest) || begin
405
+ source = template.instance_variable_get(:@upkeep_herb_original_source) || template.source
406
+ manifest = manifest_cache.fetch(
407
+ path: template.virtual_path || template.identifier,
408
+ source: source,
409
+ parse_options: MANIFEST_PARSE_OPTIONS
410
+ )
411
+ template.instance_variable_set(:@upkeep_herb_manifest, manifest)
412
+ manifest
413
+ end
414
+ end
415
+
416
+ def template_static_metadata(template)
417
+ template.instance_variable_get(:@upkeep_static_metadata) || begin
418
+ virtual_path = template.virtual_path || template.identifier
419
+ manifest = manifest_for_template(template)
420
+ metadata = {
421
+ kind: partial_template?(template) ? "fragment" : "page",
422
+ template: virtual_path,
423
+ identifier: template.identifier
424
+ }.merge(manifest_metadata(manifest)).freeze
425
+ template.instance_variable_set(:@upkeep_static_metadata, metadata)
426
+ metadata
427
+ end
428
+ end
429
+
430
+ def manifest_metadata(manifest)
431
+ return {} unless manifest
432
+
433
+ path = if manifest.respond_to?(:path)
434
+ manifest.path
435
+ else
436
+ manifest[:manifest_path] || manifest[:path]
437
+ end
438
+
439
+ fingerprint = if manifest.respond_to?(:fingerprint)
440
+ manifest.fingerprint
441
+ else
442
+ manifest[:manifest_fingerprint] || manifest[:fingerprint]
443
+ end
444
+
445
+ return {} unless path && fingerprint
446
+
447
+ {
448
+ manifest_path: path,
449
+ manifest_fingerprint: fingerprint,
450
+ manifest: {
451
+ path: path,
452
+ fingerprint: fingerprint
453
+ }
454
+ }
455
+ end
456
+
457
+ def with_frame_id(frame_id)
458
+ frame_stack.push(frame_id)
459
+ yield
460
+ ensure
461
+ frame_stack.pop
462
+ end
463
+
464
+ def current_frame_id
465
+ frame_stack.last
466
+ end
467
+
468
+ def frame_stack
469
+ Thread.current[FRAME_STACK_KEY] ||= []
470
+ end
471
+
472
+ def with_render_site(render_site)
473
+ render_site_stack.push(render_site)
474
+ yield
475
+ ensure
476
+ render_site_stack.pop
477
+ end
478
+
479
+ def current_render_site
480
+ render_site_stack.last
481
+ end
482
+
483
+ def render_site_stack
484
+ Thread.current[RENDER_SITE_STACK_KEY] ||= []
485
+ end
486
+
487
+ def manifest_cache
488
+ @manifest_cache ||= HerbSupport::ManifestCache.new
489
+ end
490
+
491
+ def reset_manifest_cache!
492
+ @manifest_cache = HerbSupport::ManifestCache.new
493
+ end
494
+
495
+ def record_collection_dependency(collection, collection_analysis: nil)
496
+ # Collection dependencies only matter to an active capture. When nothing is
497
+ # recording (e.g. a request that opted out via upkeep_reactive_request?, or any
498
+ # render outside a captured request) there is no dependency to record, and we
499
+ # must not analyze -- otherwise an opaque relation would raise/refuse on a page
500
+ # that is deliberately not reactive. This mirrors the relation-load observer,
501
+ # which also no-ops without a recorder.
502
+ return unless Runtime::Observation.recording?
503
+ return if refused_collection_analysis?(collection_analysis)
504
+
505
+ analysis = collection_analysis
506
+ analysis ||= ActiveRecordQuery.analyze(collection) if active_record_relation?(collection)
507
+ return unless analysis
508
+
509
+ dependency = Dependencies::ActiveRecordCollection.new(
510
+ primary_table: analysis.primary_table,
511
+ table_columns: analysis.table_columns,
512
+ coverage: analysis.coverage,
513
+ sql: analysis.sql,
514
+ predicates: analysis.predicates
515
+ )
516
+
517
+ Runtime::Observation.record_dependency(dependency)
518
+ end
519
+
520
+ def frame_id_for_template(metadata, locals)
521
+ if metadata.fetch(:kind) == "fragment"
522
+ "fragment:rails:#{metadata.fetch(:template)}:#{locals_identity(locals)}"
523
+ else
524
+ "page:rails:#{metadata.fetch(:template)}"
525
+ end
526
+ end
527
+
528
+ def locals_identity(locals)
529
+ record = locals.values.find { |value| value.is_a?(ActiveRecord::Base) }
530
+ return "#{record.class.table_name}:#{record.id}" if record
531
+
532
+ Digest::SHA256.hexdigest(local_metadata(locals).inspect)[0, 16]
533
+ end
534
+
535
+ def local_metadata(locals)
536
+ locals.transform_values do |value|
537
+ if value.is_a?(ActiveRecord::Base)
538
+ { table: value.class.table_name, id: value.id }
539
+ elsif value.respond_to?(:klass) && value.respond_to?(:to_sql)
540
+ { class: value.class.name, table: value.klass.table_name }
541
+ elsif value.is_a?(Array)
542
+ { class: value.class.name, size: value.size }
543
+ else
544
+ value.class.name
545
+ end
546
+ end
547
+ end
548
+
549
+ def render_options_for_replay(options)
550
+ options.each_with_object({}) do |(key, value), replay_options|
551
+ replay_options[key] = key == :locals && value.respond_to?(:dup) ? value.dup : value
552
+ end
553
+ end
554
+
555
+ def replay_render_options(options)
556
+ options.each_with_object({}) do |(key, value), replay_options|
557
+ replay_options[key] = key == :locals ? replay_locals(value || {}) : value
558
+ end
559
+ end
560
+
561
+ def replay_locals(locals)
562
+ locals.transform_values { |value| replay_value(value) }
563
+ end
564
+
565
+ def replayable_view_assigns(view, controller: nil)
566
+ source = if controller&.respond_to?(:view_assigns)
567
+ controller
568
+ elsif view.respond_to?(:view_assigns)
569
+ view
570
+ end
571
+ return {} unless source
572
+
573
+ source.view_assigns.each_with_object({}) do |(name, value), assigns|
574
+ assigns[name.to_s] = value if replayable_view_assign_value?(value)
575
+ end
576
+ end
577
+
578
+ def replayable_view_assign_value?(value)
579
+ return true if value.is_a?(ActiveRecord::Base)
580
+ return value.all? { |item| replayable_view_assign_value?(item) } if value.is_a?(Array)
581
+ return value.all? { |_key, item| replayable_view_assign_value?(item) } if value.is_a?(Hash)
582
+
583
+ false
584
+ end
585
+
586
+ def with_replayed_view_assigns(view, assigns)
587
+ originals = {}
588
+
589
+ assigns.each do |name, value|
590
+ ivar = :"@#{name}"
591
+ originals[ivar] = if view.instance_variable_defined?(ivar)
592
+ [true, view.instance_variable_get(ivar)]
593
+ else
594
+ [false, nil]
595
+ end
596
+ view.instance_variable_set(ivar, replay_value(value))
597
+ end
598
+
599
+ yield
600
+ ensure
601
+ originals&.each do |ivar, (defined, value)|
602
+ if defined
603
+ view.instance_variable_set(ivar, value)
604
+ else
605
+ view.remove_instance_variable(ivar) if view.instance_variable_defined?(ivar)
606
+ end
607
+ end
608
+ end
609
+
610
+ def snapshot_hash(values)
611
+ values.each_with_object({}) do |(key, value), snapshot|
612
+ next if key.to_s.end_with?("_iteration")
613
+
614
+ snapshot[key.to_s] = snapshot_value(value)
615
+ end
616
+ end
617
+
618
+ def snapshot_render_options(options)
619
+ options.each_with_object({}) do |(key, value), snapshot|
620
+ snapshot[key.to_s] = key == :locals ? ::Upkeep::Replay::HashValue.new(entries: snapshot_hash(value || {})) : snapshot_value(value)
621
+ end
622
+ end
623
+
624
+ def snapshot_value(value, rendered_collection: nil, relation_analysis: nil)
625
+ if value.is_a?(ActiveRecord::Base)
626
+ ::Upkeep::Replay.active_record_value(value)
627
+ elsif form_builder?(value)
628
+ ::Upkeep::Replay::RailsFormBuilderValue.new(
629
+ builder_class: value.class.name,
630
+ object_name: value.object_name,
631
+ object: snapshot_value(value.object),
632
+ options: snapshot_hash(replayable_form_builder_options(value.options))
633
+ )
634
+ elsif active_record_relation?(value)
635
+ if refused_collection_analysis?(relation_analysis)
636
+ return refused_relation_snapshot(value, relation_analysis)
637
+ end
638
+
639
+ analysis = relation_analysis || analyze_relation_for_snapshot(value)
640
+ return refused_relation_snapshot(value, analysis) if refused_collection_analysis?(analysis)
641
+
642
+ ::Upkeep::Replay::ActiveRecordRelationValue.new(
643
+ model: value.klass.name,
644
+ sql: analysis.sql,
645
+ primary_key: analysis.primary_key,
646
+ appendable: analysis.appendable?,
647
+ limit_value: analysis.limit_value,
648
+ predicates: analysis.predicates,
649
+ member_ids: rendered_collection ? relation_member_ids(analysis.primary_key, rendered_collection) : []
650
+ )
651
+ elsif value.is_a?(Array) && relation_provenance_analysis?(relation_analysis)
652
+ ::Upkeep::Replay::ActiveRecordRelationValue.new(
653
+ model: relation_analysis.model_name,
654
+ sql: relation_analysis.sql,
655
+ primary_key: relation_analysis.primary_key,
656
+ appendable: relation_analysis.appendable?,
657
+ limit_value: relation_analysis.limit_value,
658
+ predicates: relation_analysis.predicates,
659
+ member_ids: rendered_collection ? relation_member_ids(relation_analysis.primary_key, rendered_collection) : []
660
+ )
661
+ elsif value.is_a?(Array)
662
+ ::Upkeep::Replay::ArrayValue.new(items: value.map { |item| snapshot_value(item) })
663
+ elsif value.is_a?(Hash)
664
+ ::Upkeep::Replay::HashValue.new(entries: snapshot_hash(value))
665
+ elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
666
+ ::Upkeep::Replay::LiteralValue.new(value: value)
667
+ else
668
+ ::Upkeep::Replay::UnsupportedValue.new(class_name: value.class.name)
669
+ end
670
+ end
671
+
672
+ def relation_member_ids(primary_key, rendered_collection)
673
+ return [] unless primary_key
674
+
675
+ if rendered_collection.respond_to?(:to_ary)
676
+ return rendered_collection.to_ary.filter_map do |record|
677
+ record.public_send(primary_key).to_s if record.respond_to?(primary_key)
678
+ end
679
+ end
680
+
681
+ relation.pluck(primary_key).map(&:to_s)
682
+ end
683
+
684
+ def active_record_relation?(value)
685
+ value.respond_to?(:klass) && value.respond_to?(:to_sql)
686
+ end
687
+
688
+ def form_builder?(value)
689
+ defined?(ActionView::Helpers::FormBuilder) && value.is_a?(ActionView::Helpers::FormBuilder)
690
+ end
691
+
692
+ def replayable_form_builder_options(options)
693
+ options.to_h.slice(
694
+ :allow_method_names_outside_object,
695
+ :index,
696
+ :namespace,
697
+ :skip_default_ids
698
+ )
699
+ end
700
+
701
+ def handle_refused_collection(error)
702
+ raise error if Upkeep::Rails.configuration.refused_boundary_behavior == :raise
703
+
704
+ refused = RefusedCollection.new(
705
+ "opaque_active_record_relation",
706
+ error.message,
707
+ error.suggestions,
708
+ error
709
+ )
710
+ payload = {
711
+ reason: refused.reason,
712
+ message: refused.message,
713
+ suggestions: refused.suggestions,
714
+ source: "active_record_collection"
715
+ }
716
+
717
+ if Runtime::Observation.refuse_boundary(payload)
718
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
719
+ warn_refused_boundary(payload)
720
+ end
721
+ refused
722
+ end
723
+
724
+ def analyze_relation_for_snapshot(value)
725
+ ActiveRecordQuery.analyze(value)
726
+ rescue ActiveRecordQuery::OpaqueRelationError => error
727
+ handle_refused_collection(error)
728
+ end
729
+
730
+ def refused_collection_analysis?(value)
731
+ value.is_a?(RefusedCollection)
732
+ end
733
+
734
+ def relation_provenance_analysis?(value)
735
+ value.is_a?(Runtime::RelationProvenance)
736
+ end
737
+
738
+ def refused_relation_snapshot(value, refused)
739
+ ::Upkeep::Replay::RefusedActiveRecordRelationValue.new(
740
+ model: value.klass.name,
741
+ sql_digest: Digest::SHA256.hexdigest(value.to_sql)[0, 16],
742
+ reason: refused.reason
743
+ )
744
+ end
745
+
746
+ def warn_refused_boundary(payload)
747
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
748
+
749
+ ::Rails.logger.warn(
750
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
751
+ "#{payload.fetch(:suggestions).join(" ")}"
752
+ )
753
+ end
754
+
755
+ def replay_value(value)
756
+ if value.is_a?(ActiveRecord::Base)
757
+ value.class.find(value.id)
758
+ elsif form_builder?(value)
759
+ replay_form_builder(value)
760
+ elsif value.respond_to?(:spawn) && value.respond_to?(:klass)
761
+ value.spawn
762
+ elsif value.is_a?(Array)
763
+ value.map { |item| replay_value(item) }
764
+ else
765
+ value
766
+ end
767
+ end
768
+
769
+ def replay_form_builder(builder)
770
+ builder.class.new(
771
+ builder.object_name,
772
+ replay_value(builder.object),
773
+ builder.instance_variable_get(:@template),
774
+ replayable_form_builder_options(builder.options)
775
+ )
776
+ end
777
+
778
+ def replay_collection_value(collection, collection_analysis)
779
+ if collection.is_a?(Array) && relation_provenance_analysis?(collection_analysis)
780
+ return constantize(collection_analysis.model_name).find_by_sql(collection_analysis.sql)
781
+ end
782
+
783
+ replay_value(collection)
784
+ end
785
+
786
+ def collection_key(collection)
787
+ provenance = Runtime::Observation.relation_provenance_for(collection)
788
+ if provenance
789
+ {
790
+ table: provenance.primary_table,
791
+ predicate_digest: Digest::SHA256.hexdigest(provenance.sql)[0, 16],
792
+ materialized: true
793
+ }
794
+ elsif collection.respond_to?(:klass) && collection.respond_to?(:to_sql)
795
+ {
796
+ table: collection.klass.table_name,
797
+ predicate_digest: Digest::SHA256.hexdigest(collection.to_sql)[0, 16]
798
+ }
799
+ elsif collection.respond_to?(:to_ary)
800
+ { class: collection.class.name, size: collection.to_ary.size }
801
+ else
802
+ { class: collection.class.name }
803
+ end
804
+ end
805
+
806
+ def constantize(name)
807
+ name.to_s.split("::").reject(&:empty?).reduce(Object) { |scope, const_name| scope.const_get(const_name) }
808
+ end
809
+
810
+ def partial_template?(template)
811
+ File.basename(template.virtual_path.to_s).start_with?("_")
812
+ end
813
+
814
+ module ViewHelpers
815
+ UPKEEP_FRAME_BLOCK_ERROR = "upkeep_frame requires a block. Use: " \
816
+ '<%= upkeep_frame("frame-name") do %> ... <% end %>'
817
+
818
+ # Returns the stable DOM id for the current page frame.
819
+ #
820
+ # Normal templates do not need to call this directly; Upkeep's source
821
+ # instrumentation adds page-frame markers while rendering captured page
822
+ # templates. This helper is available for custom/generated markup that
823
+ # must emit the marker explicitly.
824
+ #
825
+ # @return [String]
826
+ # @raise [RuntimeError] when called outside an Upkeep page frame render.
827
+ def upkeep_page_frame_id
828
+ Upkeep::Rails::ActionViewCapture.current_frame_id ||
829
+ raise("upkeep_page_frame_id is only available while rendering an Upkeep page frame")
830
+ end
831
+
832
+ # Returns the stable DOM id for the current fragment frame.
833
+ #
834
+ # Normal partials do not need to call this directly; Upkeep's source
835
+ # instrumentation adds fragment-frame markers while rendering captured
836
+ # partial or fragment templates. This helper is available for
837
+ # custom/generated markup that must emit the marker explicitly.
838
+ #
839
+ # @return [String]
840
+ # @raise [RuntimeError] when called outside an Upkeep frame render.
841
+ def upkeep_frame_id
842
+ Upkeep::Rails::ActionViewCapture.current_frame_id ||
843
+ raise("upkeep_frame_id is only available while rendering an Upkeep frame")
844
+ end
845
+
846
+ # Advanced escape hatch for a custom render-site frame.
847
+ #
848
+ # Ordinary Rails ERB does not need this helper. Upkeep instruments safe
849
+ # partial collection renders automatically and inserts the internal
850
+ # markers needed for page, fragment, and render-site delivery.
851
+ #
852
+ # Use this only when a generated/helper-built boundary cannot be derived
853
+ # from template source. The helper is output-producing and returns the
854
+ # rendered block:
855
+ #
856
+ # <%= upkeep_frame "cards" do %>
857
+ # <%= render partial: "cards/card", collection: @cards, as: :card %>
858
+ # <% end %>
859
+ #
860
+ # Manual callers are responsible for rendering a stable DOM target for
861
+ # the site. Instrumented templates add that target automatically for
862
+ # normal render sites.
863
+ #
864
+ # @param site_id [#to_s] stable application id for this frame.
865
+ # @param manifest_path [String, nil] template manifest path for replay diagnostics.
866
+ # @param manifest_fingerprint [String, nil] template manifest fingerprint for replay diagnostics.
867
+ # @return [String, ActiveSupport::SafeBuffer] rendered block HTML.
868
+ # @raise [ArgumentError] when called without a block.
869
+ def upkeep_frame(site_id, manifest_path: nil, manifest_fingerprint: nil, &block)
870
+ raise ArgumentError, UPKEEP_FRAME_BLOCK_ERROR unless block
871
+
872
+ html = Upkeep::Rails::ActionViewCapture.with_render_site(
873
+ {
874
+ site_id: site_id,
875
+ manifest_path: manifest_path,
876
+ manifest_fingerprint: manifest_fingerprint
877
+ }.compact
878
+ ) do
879
+ capture(&block)
880
+ end
881
+
882
+ html.respond_to?(:html_safe) ? html.html_safe : html
883
+ end
884
+ end
885
+
886
+ module TemplateHook
887
+ def render(view, locals, buffer = nil, implicit_locals: [], add_to_stack: true, &block)
888
+ Upkeep::Rails::ActionViewCapture.capture_template(
889
+ self,
890
+ view,
891
+ locals,
892
+ implicit_locals: implicit_locals,
893
+ add_to_stack: add_to_stack,
894
+ block: block
895
+ ) do
896
+ super
897
+ end
898
+ end
899
+ end
900
+
901
+ module CollectionRendererHook
902
+ def render_collection_with_partial(collection, partial, context, block)
903
+ collection_analysis = Upkeep::Rails::ActionViewCapture.collection_analysis(collection)
904
+ source_collection, rendered_collection = Upkeep::Rails::ActionViewCapture.collection_capture_pair(collection)
905
+ Upkeep::Rails::ActionViewCapture.capture_collection(partial, source_collection, rendered_collection, context, @options, block, collection_analysis: collection_analysis) do
906
+ super(rendered_collection, partial, context, block)
907
+ end
908
+ end
909
+
910
+ def render_collection_derive_partial(collection, context, block)
911
+ collection_analysis = Upkeep::Rails::ActionViewCapture.collection_analysis(collection)
912
+ source_collection, rendered_collection = Upkeep::Rails::ActionViewCapture.collection_capture_pair(collection)
913
+ Upkeep::Rails::ActionViewCapture.capture_collection(:derived, source_collection, rendered_collection, context, @options, block, collection_analysis: collection_analysis) do
914
+ super(rendered_collection, context, block)
915
+ end
916
+ end
917
+ end
918
+ end
919
+ end
920
+ end