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