upkeep-rails 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,765 @@
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
+ action_view_helpers: false,
23
+ transform_conditionals: false
24
+ ).freeze
25
+
26
+ REPLAY_HTTP_ENV_KEYS = %w[
27
+ HTTP_ACCEPT
28
+ HTTP_HOST
29
+ HTTP_X_FORWARDED_HOST
30
+ HTTP_X_FORWARDED_PROTO
31
+ ].freeze
32
+
33
+ REQUEST_REPLAY_ENV_KEYS = {
34
+ "host" => "HTTP_HOST",
35
+ "request_method" => "REQUEST_METHOD",
36
+ "user_agent" => "HTTP_USER_AGENT",
37
+ "remote_ip" => "REMOTE_ADDR"
38
+ }.freeze
39
+
40
+ RefusedCollection = Data.define(:reason, :message, :suggestions, :error)
41
+
42
+ def install
43
+ return if @installed
44
+
45
+ ::ActionView::Template.prepend(TemplateHook)
46
+ ::ActionView::CollectionRenderer.prepend(CollectionRendererHook)
47
+ ::ActionView::Base.include(ViewHelpers)
48
+
49
+ @installed = true
50
+ end
51
+
52
+ def installed?
53
+ !!@installed
54
+ end
55
+
56
+ def capture_template(template, view, locals, implicit_locals:, add_to_stack:, block:)
57
+ instrument_template_source!(template)
58
+ captured_locals = locals.dup
59
+ metadata = template_metadata(template, captured_locals)
60
+ controller = controller_for_view(view)
61
+ page_controller = controller if metadata.fetch(:kind) == "page"
62
+ metadata = metadata.merge(controller: controller_metadata(page_controller)) if page_controller
63
+ frame_id = frame_id_for_template(metadata, captured_locals)
64
+ recipe = if page_controller
65
+ controller_page_recipe(frame_id: frame_id, controller: page_controller, metadata: metadata)
66
+ else
67
+ template_recipe(
68
+ frame_id: frame_id,
69
+ template: template,
70
+ view: view,
71
+ controller: controller,
72
+ locals: captured_locals,
73
+ metadata: metadata,
74
+ implicit_locals: implicit_locals,
75
+ add_to_stack: add_to_stack,
76
+ block: block
77
+ )
78
+ end
79
+
80
+ Runtime::Observation.capture_frame(frame_id, metadata.merge(recipe: recipe)) do
81
+ with_frame_id(frame_id) { yield }
82
+ end
83
+ end
84
+
85
+ def capture_collection(partial, collection, rendered_collection, context, options, block, collection_analysis: nil)
86
+ captured_options = render_options_for_replay(options)
87
+ metadata = collection_metadata(partial, collection, render_site: current_render_site)
88
+ frame_id = "site:#{metadata.fetch(:site_id)}"
89
+ recipe = collection_recipe(
90
+ frame_id: frame_id,
91
+ partial: partial,
92
+ collection: collection,
93
+ rendered_collection: rendered_collection,
94
+ context: context,
95
+ controller: controller_for_view(context),
96
+ options: captured_options,
97
+ metadata: metadata,
98
+ block: block,
99
+ collection_analysis: collection_analysis
100
+ )
101
+
102
+ Runtime::Observation.capture_frame(frame_id, metadata.merge(recipe: recipe)) do
103
+ record_collection_dependency(collection, collection_analysis: collection_analysis)
104
+ yield
105
+ end
106
+ end
107
+
108
+ def collection_analysis(collection)
109
+ provenance = Runtime::Observation.relation_provenance_for(collection)
110
+ return provenance if provenance
111
+ return unless active_record_relation?(collection)
112
+
113
+ ActiveRecordQuery.analyze(collection)
114
+ rescue ActiveRecordQuery::OpaqueRelationError => error
115
+ handle_refused_collection(error)
116
+ end
117
+
118
+ def collection_capture_pair(collection)
119
+ if active_record_relation?(collection)
120
+ rendered_collection = Runtime::RelationObserver.suppress_dependency_tracking { collection.to_a }
121
+ [collection, rendered_collection]
122
+ else
123
+ [collection, collection]
124
+ end
125
+ end
126
+
127
+ def template_metadata(template, locals)
128
+ template_static_metadata(template).merge(locals: local_metadata(locals))
129
+ end
130
+
131
+ def collection_metadata(partial, collection, render_site: nil)
132
+ collection_key = collection_key(collection)
133
+ site_id = render_site&.fetch(:site_id) ||
134
+ Digest::SHA256.hexdigest(["rails_collection", partial.to_s, collection_key].inspect)[0, 16]
135
+
136
+ {
137
+ kind: "render_site",
138
+ site_id: site_id,
139
+ partial: partial.to_s,
140
+ collection: collection_key
141
+ }.merge(manifest_metadata(render_site))
142
+ end
143
+
144
+ def template_recipe(frame_id:, template:, view:, controller:, locals:, metadata:, implicit_locals:, add_to_stack:, block:)
145
+ target_kind = metadata.fetch(:kind) == "fragment" ? "fragment" : "page"
146
+ ::Upkeep::Replay::Recipe.new(
147
+ kind: metadata.fetch(:kind).to_sym,
148
+ frame_id: frame_id,
149
+ target_kind: target_kind,
150
+ target_id: frame_id,
151
+ template: metadata.fetch(:template),
152
+ metadata: metadata,
153
+ runtime: "rails",
154
+ replay: (target_kind == "fragment" ? ::Upkeep::Replay::Fragment : ::Upkeep::Replay::Template).new(
155
+ controller_class: controller&.class&.name,
156
+ template: metadata.fetch(:template),
157
+ locals: snapshot_hash(locals)
158
+ )
159
+ ) do
160
+ template.render(
161
+ view,
162
+ replay_locals(locals),
163
+ nil,
164
+ implicit_locals: implicit_locals,
165
+ add_to_stack: add_to_stack,
166
+ &block
167
+ )
168
+ end
169
+ end
170
+
171
+ def controller_page_recipe(frame_id:, controller:, metadata:)
172
+ controller_class = controller.class
173
+ action_name = controller.action_name
174
+ ambient_inputs = request_ambient_replay_inputs
175
+ env = replay_env(
176
+ controller.request.env,
177
+ path_parameters: controller.request.path_parameters,
178
+ ambient_inputs: ambient_inputs
179
+ )
180
+
181
+ ::Upkeep::Replay::Recipe.new(
182
+ kind: :page,
183
+ frame_id: frame_id,
184
+ target_kind: "page",
185
+ target_id: frame_id,
186
+ template: metadata.fetch(:template),
187
+ metadata: metadata,
188
+ runtime: "rails",
189
+ replay: ::Upkeep::Replay::ControllerPage.new(
190
+ controller_class: controller_class.name,
191
+ action: action_name,
192
+ env: serializable_replay_env(
193
+ controller.request.env,
194
+ path_parameters: controller.request.path_parameters,
195
+ ambient_inputs: ambient_inputs
196
+ )
197
+ )
198
+ ) do
199
+ _status, _headers, body = ControllerRuntime.suppress do
200
+ controller_class.action(action_name).call(Replay.rack_env(env))
201
+ end
202
+ collect_response_body(body)
203
+ end
204
+ end
205
+
206
+ def collection_recipe(frame_id:, partial:, collection:, rendered_collection:, context:, controller:, options:, metadata:, block:, collection_analysis: nil)
207
+ ::Upkeep::Replay::Recipe.new(
208
+ kind: :render_site,
209
+ frame_id: frame_id,
210
+ target_kind: "render_site",
211
+ target_id: metadata.fetch(:site_id),
212
+ metadata: metadata,
213
+ runtime: "rails",
214
+ replay: ::Upkeep::Replay::Collection.new(
215
+ controller_class: controller&.class&.name,
216
+ partial: partial == :derived ? "derived" : partial.to_s,
217
+ collection: snapshot_value(collection, rendered_collection: rendered_collection, relation_analysis: collection_analysis),
218
+ options: snapshot_render_options(options)
219
+ )
220
+ ) do
221
+ replay_options = replay_render_options(options)
222
+ replay_options[:partial] = partial unless partial == :derived
223
+ replay_options[:collection] = replay_collection_value(collection, collection_analysis)
224
+ context.render(replay_options, &block)
225
+ end
226
+ end
227
+
228
+ def controller_for_view(view)
229
+ return unless view.respond_to?(:controller)
230
+
231
+ controller = view.controller
232
+ return unless controller&.respond_to?(:request) && controller.respond_to?(:action_name)
233
+
234
+ controller
235
+ end
236
+
237
+ def controller_metadata(controller)
238
+ request = controller.request
239
+ {
240
+ class: controller.class.name,
241
+ action: controller.action_name,
242
+ request_method: request.env["REQUEST_METHOD"].to_s,
243
+ path: request.env["PATH_INFO"].to_s,
244
+ query_string_digest: Digest::SHA256.hexdigest(request.env["QUERY_STRING"].to_s)[0, 16],
245
+ path_parameters: request.path_parameters.keys.map(&:to_s).sort
246
+ }
247
+ end
248
+
249
+ def serializable_replay_env(env, path_parameters: nil, ambient_inputs: {})
250
+ replay_env(env, path_parameters: path_parameters, ambient_inputs: ambient_inputs).reject do |key, _value|
251
+ key == "rack.input" || key == "rack.errors"
252
+ end
253
+ end
254
+
255
+ def replay_env(env, path_parameters: nil, ambient_inputs: {})
256
+ copy = env.each_with_object({}) do |(key, value), replay|
257
+ replay[key] = replay_env_value(value) if replay_env_key?(key)
258
+ end
259
+
260
+ session_snapshot = session_replay_snapshot(
261
+ env["rack.session"],
262
+ observed_values: ambient_inputs.fetch(:session, {})
263
+ )
264
+ cookie_header = cookie_replay_header(ambient_inputs.fetch(:cookie, {}))
265
+ copy["rack.session"] = session_snapshot if session_snapshot
266
+ copy["HTTP_COOKIE"] = cookie_header if cookie_header
267
+ request_replay_env(ambient_inputs.fetch(:request, {})).each do |key, value|
268
+ copy[key] = value
269
+ end
270
+ copy["rack.input"] = StringIO.new
271
+ copy["rack.errors"] ||= StringIO.new
272
+ copy["action_dispatch.request.path_parameters"] = path_parameters if path_parameters
273
+ copy
274
+ end
275
+
276
+ def replay_env_key?(key)
277
+ return false if key == "HTTP_COOKIE"
278
+
279
+ REPLAY_HTTP_ENV_KEYS.include?(key) ||
280
+ key.start_with?("REQUEST_") ||
281
+ key.start_with?("SERVER_") ||
282
+ key.start_with?("REMOTE_") ||
283
+ key == "rack.url_scheme" ||
284
+ %w[
285
+ CONTENT_LENGTH
286
+ CONTENT_TYPE
287
+ HTTPS
288
+ PATH_INFO
289
+ QUERY_STRING
290
+ SCRIPT_NAME
291
+ action_dispatch.request.path_parameters
292
+ ].include?(key)
293
+ end
294
+
295
+ def replay_env_value(value)
296
+ case value
297
+ when Hash
298
+ value.transform_values { |nested_value| replay_env_scalar_value(nested_value) }
299
+ when Array
300
+ value.map { |nested_value| replay_env_scalar_value(nested_value) }
301
+ else
302
+ replay_env_scalar_value(value)
303
+ end
304
+ end
305
+
306
+ def replay_env_scalar_value(value)
307
+ case value
308
+ when Hash
309
+ value.transform_values { |nested_value| replay_env_scalar_value(nested_value) }
310
+ when Array
311
+ value.map { |nested_value| replay_env_scalar_value(nested_value) }
312
+ else
313
+ value
314
+ end
315
+ end
316
+
317
+ def request_ambient_replay_inputs
318
+ Runtime::Observation.recorder&.ambient_replay_inputs_for(Runtime::Recorder::REQUEST_NODE_ID) || {}
319
+ end
320
+
321
+ def session_replay_snapshot(session, observed_values:)
322
+ values = observed_values.transform_keys(&:to_s)
323
+ return if values.empty?
324
+
325
+ session_id = session_id_for_replay(session)
326
+ values = values.merge("session_id" => session_id.to_s) if session_id && !session_id.to_s.empty?
327
+
328
+ {
329
+ "__upkeep_replay_type" => "rack_session",
330
+ "values" => replay_env_scalar_value(values)
331
+ }
332
+ end
333
+
334
+ def session_id_for_replay(session)
335
+ session.id if session.respond_to?(:id)
336
+ rescue StandardError
337
+ nil
338
+ end
339
+
340
+ def cookie_replay_header(observed_values)
341
+ values = observed_values.transform_keys(&:to_s).reject { |_key, value| value.nil? }
342
+ return if values.empty?
343
+
344
+ values.map do |key, value|
345
+ "#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"
346
+ end.join("; ")
347
+ end
348
+
349
+ def request_replay_env(observed_values)
350
+ observed_values.transform_keys(&:to_s).each_with_object({}) do |(key, value), replay_env|
351
+ env_key = REQUEST_REPLAY_ENV_KEYS[key]
352
+ replay_env[env_key] = replay_env_scalar_value(value) if env_key && !value.nil?
353
+ end
354
+ end
355
+
356
+ def collect_response_body(body)
357
+ body.each.to_a.join
358
+ ensure
359
+ body.close if body.respond_to?(:close)
360
+ end
361
+
362
+ def instrument_template_source!(template)
363
+ return if template.instance_variable_get(:@upkeep_herb_instrumented)
364
+ return unless erb_template?(template)
365
+
366
+ manifest = manifest_for_template(template)
367
+ instrumented_source = HerbSupport::SourceInstrumenter.new(manifest: manifest).instrument(template.source)
368
+ template.instance_variable_set(:@upkeep_herb_original_source, template.source)
369
+ template.instance_variable_set(:@source, instrumented_source)
370
+ template.instance_variable_set(:@upkeep_herb_instrumented, true)
371
+ end
372
+
373
+ def erb_template?(template)
374
+ template.identifier.to_s.end_with?(".erb") || template.respond_to?(:handler) && template.handler.class.name.include?("ERB")
375
+ end
376
+
377
+ def manifest_for_template(template)
378
+ template.instance_variable_get(:@upkeep_herb_manifest) || begin
379
+ source = template.instance_variable_get(:@upkeep_herb_original_source) || template.source
380
+ manifest = manifest_cache.fetch(
381
+ path: template.virtual_path || template.identifier,
382
+ source: source,
383
+ parse_options: MANIFEST_PARSE_OPTIONS
384
+ )
385
+ template.instance_variable_set(:@upkeep_herb_manifest, manifest)
386
+ manifest
387
+ end
388
+ end
389
+
390
+ def template_static_metadata(template)
391
+ template.instance_variable_get(:@upkeep_static_metadata) || begin
392
+ virtual_path = template.virtual_path || template.identifier
393
+ manifest = manifest_for_template(template)
394
+ metadata = {
395
+ kind: partial_template?(template) ? "fragment" : "page",
396
+ template: virtual_path,
397
+ identifier: template.identifier
398
+ }.merge(manifest_metadata(manifest)).freeze
399
+ template.instance_variable_set(:@upkeep_static_metadata, metadata)
400
+ metadata
401
+ end
402
+ end
403
+
404
+ def manifest_metadata(manifest)
405
+ return {} unless manifest
406
+
407
+ path = if manifest.respond_to?(:path)
408
+ manifest.path
409
+ else
410
+ manifest[:manifest_path] || manifest[:path]
411
+ end
412
+
413
+ fingerprint = if manifest.respond_to?(:fingerprint)
414
+ manifest.fingerprint
415
+ else
416
+ manifest[:manifest_fingerprint] || manifest[:fingerprint]
417
+ end
418
+
419
+ return {} unless path && fingerprint
420
+
421
+ {
422
+ manifest_path: path,
423
+ manifest_fingerprint: fingerprint,
424
+ manifest: {
425
+ path: path,
426
+ fingerprint: fingerprint
427
+ }
428
+ }
429
+ end
430
+
431
+ def with_frame_id(frame_id)
432
+ frame_stack.push(frame_id)
433
+ yield
434
+ ensure
435
+ frame_stack.pop
436
+ end
437
+
438
+ def current_frame_id
439
+ frame_stack.last
440
+ end
441
+
442
+ def frame_stack
443
+ Thread.current[FRAME_STACK_KEY] ||= []
444
+ end
445
+
446
+ def with_render_site(render_site)
447
+ render_site_stack.push(render_site)
448
+ yield
449
+ ensure
450
+ render_site_stack.pop
451
+ end
452
+
453
+ def current_render_site
454
+ render_site_stack.last
455
+ end
456
+
457
+ def render_site_stack
458
+ Thread.current[RENDER_SITE_STACK_KEY] ||= []
459
+ end
460
+
461
+ def manifest_cache
462
+ @manifest_cache ||= HerbSupport::ManifestCache.new
463
+ end
464
+
465
+ def reset_manifest_cache!
466
+ @manifest_cache = HerbSupport::ManifestCache.new
467
+ end
468
+
469
+ def record_collection_dependency(collection, collection_analysis: nil)
470
+ return if refused_collection_analysis?(collection_analysis)
471
+
472
+ analysis = collection_analysis
473
+ analysis ||= ActiveRecordQuery.analyze(collection) if active_record_relation?(collection)
474
+ return unless analysis
475
+
476
+ dependency = Dependencies::ActiveRecordCollection.new(
477
+ primary_table: analysis.primary_table,
478
+ table_columns: analysis.table_columns,
479
+ coverage: analysis.coverage,
480
+ sql: analysis.sql,
481
+ predicates: analysis.predicates
482
+ )
483
+
484
+ Runtime::Observation.record_dependency(dependency)
485
+ end
486
+
487
+ def frame_id_for_template(metadata, locals)
488
+ if metadata.fetch(:kind) == "fragment"
489
+ "fragment:rails:#{metadata.fetch(:template)}:#{locals_identity(locals)}"
490
+ else
491
+ "page:rails:#{metadata.fetch(:template)}"
492
+ end
493
+ end
494
+
495
+ def locals_identity(locals)
496
+ record = locals.values.find { |value| value.is_a?(ActiveRecord::Base) }
497
+ return "#{record.class.table_name}:#{record.id}" if record
498
+
499
+ Digest::SHA256.hexdigest(local_metadata(locals).inspect)[0, 16]
500
+ end
501
+
502
+ def local_metadata(locals)
503
+ locals.transform_values do |value|
504
+ if value.is_a?(ActiveRecord::Base)
505
+ { table: value.class.table_name, id: value.id }
506
+ elsif value.respond_to?(:klass) && value.respond_to?(:to_sql)
507
+ { class: value.class.name, table: value.klass.table_name }
508
+ elsif value.is_a?(Array)
509
+ { class: value.class.name, size: value.size }
510
+ else
511
+ value.class.name
512
+ end
513
+ end
514
+ end
515
+
516
+ def render_options_for_replay(options)
517
+ options.each_with_object({}) do |(key, value), replay_options|
518
+ replay_options[key] = key == :locals && value.respond_to?(:dup) ? value.dup : value
519
+ end
520
+ end
521
+
522
+ def replay_render_options(options)
523
+ options.each_with_object({}) do |(key, value), replay_options|
524
+ replay_options[key] = key == :locals ? replay_locals(value || {}) : value
525
+ end
526
+ end
527
+
528
+ def replay_locals(locals)
529
+ locals.transform_values { |value| replay_value(value) }
530
+ end
531
+
532
+ def snapshot_hash(values)
533
+ values.each_with_object({}) do |(key, value), snapshot|
534
+ next if key.to_s.end_with?("_iteration")
535
+
536
+ snapshot[key.to_s] = snapshot_value(value)
537
+ end
538
+ end
539
+
540
+ def snapshot_render_options(options)
541
+ options.each_with_object({}) do |(key, value), snapshot|
542
+ snapshot[key.to_s] = key == :locals ? ::Upkeep::Replay::HashValue.new(entries: snapshot_hash(value || {})) : snapshot_value(value)
543
+ end
544
+ end
545
+
546
+ def snapshot_value(value, rendered_collection: nil, relation_analysis: nil)
547
+ if value.is_a?(ActiveRecord::Base)
548
+ ::Upkeep::Replay.active_record_value(value)
549
+ elsif active_record_relation?(value)
550
+ if refused_collection_analysis?(relation_analysis)
551
+ return refused_relation_snapshot(value, relation_analysis)
552
+ end
553
+
554
+ analysis = relation_analysis || analyze_relation_for_snapshot(value)
555
+ return refused_relation_snapshot(value, analysis) if refused_collection_analysis?(analysis)
556
+
557
+ ::Upkeep::Replay::ActiveRecordRelationValue.new(
558
+ model: value.klass.name,
559
+ sql: analysis.sql,
560
+ primary_key: analysis.primary_key,
561
+ appendable: analysis.appendable?,
562
+ limit_value: analysis.limit_value,
563
+ predicates: analysis.predicates,
564
+ member_ids: rendered_collection ? relation_member_ids(analysis.primary_key, rendered_collection) : []
565
+ )
566
+ elsif value.is_a?(Array) && relation_provenance_analysis?(relation_analysis)
567
+ ::Upkeep::Replay::ActiveRecordRelationValue.new(
568
+ model: relation_analysis.model_name,
569
+ sql: relation_analysis.sql,
570
+ primary_key: relation_analysis.primary_key,
571
+ appendable: relation_analysis.appendable?,
572
+ limit_value: relation_analysis.limit_value,
573
+ predicates: relation_analysis.predicates,
574
+ member_ids: rendered_collection ? relation_member_ids(relation_analysis.primary_key, rendered_collection) : []
575
+ )
576
+ elsif value.is_a?(Array)
577
+ ::Upkeep::Replay::ArrayValue.new(items: value.map { |item| snapshot_value(item) })
578
+ elsif value.is_a?(Hash)
579
+ ::Upkeep::Replay::HashValue.new(entries: snapshot_hash(value))
580
+ elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
581
+ ::Upkeep::Replay::LiteralValue.new(value: value)
582
+ else
583
+ ::Upkeep::Replay::UnsupportedValue.new(class_name: value.class.name)
584
+ end
585
+ end
586
+
587
+ def relation_member_ids(primary_key, rendered_collection)
588
+ return [] unless primary_key
589
+
590
+ if rendered_collection.respond_to?(:to_ary)
591
+ return rendered_collection.to_ary.filter_map do |record|
592
+ record.public_send(primary_key).to_s if record.respond_to?(primary_key)
593
+ end
594
+ end
595
+
596
+ relation.pluck(primary_key).map(&:to_s)
597
+ end
598
+
599
+ def active_record_relation?(value)
600
+ value.respond_to?(:klass) && value.respond_to?(:to_sql)
601
+ end
602
+
603
+ def handle_refused_collection(error)
604
+ raise error if Upkeep::Rails.configuration.refused_boundary_behavior == :raise
605
+
606
+ refused = RefusedCollection.new(
607
+ "opaque_active_record_relation",
608
+ error.message,
609
+ error.suggestions,
610
+ error
611
+ )
612
+ payload = {
613
+ reason: refused.reason,
614
+ message: refused.message,
615
+ suggestions: refused.suggestions,
616
+ source: "active_record_collection"
617
+ }
618
+
619
+ if Runtime::Observation.refuse_boundary(payload)
620
+ ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
621
+ warn_refused_boundary(payload)
622
+ end
623
+ refused
624
+ end
625
+
626
+ def analyze_relation_for_snapshot(value)
627
+ ActiveRecordQuery.analyze(value)
628
+ rescue ActiveRecordQuery::OpaqueRelationError => error
629
+ handle_refused_collection(error)
630
+ end
631
+
632
+ def refused_collection_analysis?(value)
633
+ value.is_a?(RefusedCollection)
634
+ end
635
+
636
+ def relation_provenance_analysis?(value)
637
+ value.is_a?(Runtime::RelationProvenance)
638
+ end
639
+
640
+ def refused_relation_snapshot(value, refused)
641
+ ::Upkeep::Replay::RefusedActiveRecordRelationValue.new(
642
+ model: value.klass.name,
643
+ sql_digest: Digest::SHA256.hexdigest(value.to_sql)[0, 16],
644
+ reason: refused.reason
645
+ )
646
+ end
647
+
648
+ def warn_refused_boundary(payload)
649
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
650
+
651
+ ::Rails.logger.warn(
652
+ "Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
653
+ "#{payload.fetch(:suggestions).join(" ")}"
654
+ )
655
+ end
656
+
657
+ def replay_value(value)
658
+ if value.is_a?(ActiveRecord::Base)
659
+ value.class.find(value.id)
660
+ elsif value.respond_to?(:spawn) && value.respond_to?(:klass)
661
+ value.spawn
662
+ elsif value.is_a?(Array)
663
+ value.map { |item| replay_value(item) }
664
+ else
665
+ value
666
+ end
667
+ end
668
+
669
+ def replay_collection_value(collection, collection_analysis)
670
+ if collection.is_a?(Array) && relation_provenance_analysis?(collection_analysis)
671
+ return constantize(collection_analysis.model_name).find_by_sql(collection_analysis.sql)
672
+ end
673
+
674
+ replay_value(collection)
675
+ end
676
+
677
+ def collection_key(collection)
678
+ provenance = Runtime::Observation.relation_provenance_for(collection)
679
+ if provenance
680
+ {
681
+ table: provenance.primary_table,
682
+ predicate_digest: Digest::SHA256.hexdigest(provenance.sql)[0, 16],
683
+ materialized: true
684
+ }
685
+ elsif collection.respond_to?(:klass) && collection.respond_to?(:to_sql)
686
+ {
687
+ table: collection.klass.table_name,
688
+ predicate_digest: Digest::SHA256.hexdigest(collection.to_sql)[0, 16]
689
+ }
690
+ elsif collection.respond_to?(:to_ary)
691
+ { class: collection.class.name, size: collection.to_ary.size }
692
+ else
693
+ { class: collection.class.name }
694
+ end
695
+ end
696
+
697
+ def constantize(name)
698
+ name.to_s.split("::").reject(&:empty?).reduce(Object) { |scope, const_name| scope.const_get(const_name) }
699
+ end
700
+
701
+ def partial_template?(template)
702
+ File.basename(template.virtual_path.to_s).start_with?("_")
703
+ end
704
+
705
+ module ViewHelpers
706
+ def upkeep_page_frame_id
707
+ Upkeep::Rails::ActionViewCapture.current_frame_id ||
708
+ raise("upkeep_page_frame_id is only available while rendering an Upkeep page frame")
709
+ end
710
+
711
+ def upkeep_frame_id
712
+ Upkeep::Rails::ActionViewCapture.current_frame_id ||
713
+ raise("upkeep_frame_id is only available while rendering an Upkeep frame")
714
+ end
715
+
716
+ def render_site(site_id, manifest_path: nil, manifest_fingerprint: nil)
717
+ html = Upkeep::Rails::ActionViewCapture.with_render_site(
718
+ {
719
+ site_id: site_id,
720
+ manifest_path: manifest_path,
721
+ manifest_fingerprint: manifest_fingerprint
722
+ }.compact
723
+ ) do
724
+ yield
725
+ end
726
+
727
+ %(<upkeep-render-site data-upkeep-render-site="#{CGI.escapeHTML(site_id.to_s)}">#{html}</upkeep-render-site>).html_safe
728
+ end
729
+ end
730
+
731
+ module TemplateHook
732
+ def render(view, locals, buffer = nil, implicit_locals: [], add_to_stack: true, &block)
733
+ Upkeep::Rails::ActionViewCapture.capture_template(
734
+ self,
735
+ view,
736
+ locals,
737
+ implicit_locals: implicit_locals,
738
+ add_to_stack: add_to_stack,
739
+ block: block
740
+ ) do
741
+ super
742
+ end
743
+ end
744
+ end
745
+
746
+ module CollectionRendererHook
747
+ def render_collection_with_partial(collection, partial, context, block)
748
+ collection_analysis = Upkeep::Rails::ActionViewCapture.collection_analysis(collection)
749
+ source_collection, rendered_collection = Upkeep::Rails::ActionViewCapture.collection_capture_pair(collection)
750
+ Upkeep::Rails::ActionViewCapture.capture_collection(partial, source_collection, rendered_collection, context, @options, block, collection_analysis: collection_analysis) do
751
+ super(rendered_collection, partial, context, block)
752
+ end
753
+ end
754
+
755
+ def render_collection_derive_partial(collection, context, block)
756
+ collection_analysis = Upkeep::Rails::ActionViewCapture.collection_analysis(collection)
757
+ source_collection, rendered_collection = Upkeep::Rails::ActionViewCapture.collection_capture_pair(collection)
758
+ Upkeep::Rails::ActionViewCapture.capture_collection(:derived, source_collection, rendered_collection, context, @options, block, collection_analysis: collection_analysis) do
759
+ super(rendered_collection, context, block)
760
+ end
761
+ end
762
+ end
763
+ end
764
+ end
765
+ end