upkeep-rails 0.2.5-aarch64-linux-gnu

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