upkeep-rails 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. metadata +308 -0
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ class ConfigurationError < StandardError; end
6
+
7
+ class Configuration
8
+ SUBSCRIPTION_STORES = [:active_record, :memory].freeze
9
+ REFUSED_BOUNDARY_BEHAVIORS = [:raise, :warn].freeze
10
+ IDENTITY_SOURCES = [:current, :session, :cookie, :warden].freeze
11
+ DELIVERY_ADAPTERS = [:async, :active_job, :inline].freeze
12
+
13
+ class IdentityDefinition
14
+ attr_reader :name, :source, :source_key, :subscribe_block
15
+
16
+ def initialize(name:, source:, source_key:, subscribe_block:, absent_block: nil)
17
+ @name = name.to_sym
18
+ @source = source.to_sym
19
+ @source_key = source_key
20
+ @subscribe_block = subscribe_block
21
+ @absent_block = absent_block
22
+ end
23
+
24
+ def matches_dependency?(dependency)
25
+ case source
26
+ when :current
27
+ dependency.source == :current_attribute &&
28
+ metadata_value(dependency, :owner) == source_key.fetch(:owner) &&
29
+ metadata_value(dependency, :name) == source_key.fetch(:name)
30
+ when :session
31
+ dependency.source == :session && metadata_value(dependency, :key) == source_key
32
+ when :cookie
33
+ dependency.source == :cookie && metadata_value(dependency, :key) == source_key
34
+ when :warden
35
+ dependency.source == :warden_user && metadata_value(dependency, :scope) == source_key
36
+ else
37
+ false
38
+ end
39
+ end
40
+
41
+ def matches_source?(source, key)
42
+ source = source.to_sym
43
+
44
+ case self.source
45
+ when :current
46
+ source == :current &&
47
+ key.fetch(:owner).to_s == source_key.fetch(:owner) &&
48
+ key.fetch(:name).to_s == source_key.fetch(:name)
49
+ when :session, :cookie, :warden
50
+ source == self.source && key.to_s == source_key
51
+ else
52
+ false
53
+ end
54
+ end
55
+
56
+ def absent?(value)
57
+ return value.nil? unless @absent_block
58
+
59
+ @absent_block.arity == 1 ? @absent_block.call(value) : @absent_block.call
60
+ end
61
+
62
+ def absent_dependency?(dependency)
63
+ Upkeep::Dependencies.identity_absent_for?(dependency, name)
64
+ end
65
+
66
+ def source_label
67
+ case source
68
+ when :current
69
+ "#{source_key.fetch(:owner)}.#{source_key.fetch(:name)}"
70
+ when :session
71
+ "session[:#{source_key}]"
72
+ when :cookie
73
+ "cookies[:#{source_key}]"
74
+ when :warden
75
+ "warden.user(:#{source_key})"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def metadata_value(dependency, key)
82
+ value = dependency.metadata[key] || dependency.metadata[key.to_s]
83
+ value.to_s
84
+ end
85
+ end
86
+
87
+ class IdentityBuilder
88
+ attr_reader :subscribe_block, :absent_block
89
+
90
+ def absent_if(&block)
91
+ @absent_block = block
92
+ end
93
+
94
+ def subscribe(&block)
95
+ @subscribe_block = block
96
+ end
97
+ end
98
+
99
+ attr_accessor :enabled
100
+ attr_accessor :activation_token_expires_in
101
+ attr_accessor :delivery_batch_window
102
+ attr_accessor :delivery_queue
103
+ attr_reader :subscription_store
104
+ attr_reader :delivery_adapter
105
+
106
+ def initialize
107
+ @enabled = true
108
+ @subscription_store = :active_record
109
+ @delivery_adapter = :async
110
+ @delivery_queue = :upkeep_realtime
111
+ @delivery_batch_window = 0.01
112
+ @refused_boundary_behavior = nil
113
+ @activation_token_expires_in = 24 * 60 * 60
114
+ @identity_definitions = {}
115
+ end
116
+
117
+ def subscription_store=(value)
118
+ value = value.to_sym if value.respond_to?(:to_sym)
119
+
120
+ unless SUBSCRIPTION_STORES.include?(value)
121
+ raise ConfigurationError,
122
+ "Unknown Upkeep subscription_store #{value.inspect}; expected one of #{SUBSCRIPTION_STORES.join(", ")}"
123
+ end
124
+
125
+ @subscription_store = value
126
+ end
127
+
128
+ def delivery_adapter=(value)
129
+ value = value.to_sym if value.respond_to?(:to_sym)
130
+
131
+ unless DELIVERY_ADAPTERS.include?(value)
132
+ raise ConfigurationError,
133
+ "Unknown Upkeep delivery_adapter #{value.inspect}; expected one of #{DELIVERY_ADAPTERS.join(", ")}"
134
+ end
135
+
136
+ @delivery_adapter = value
137
+ end
138
+
139
+ def refused_boundary_behavior
140
+ @refused_boundary_behavior || default_refused_boundary_behavior
141
+ end
142
+
143
+ def refused_boundary_behavior=(value)
144
+ value = value.to_sym if value.respond_to?(:to_sym)
145
+
146
+ unless REFUSED_BOUNDARY_BEHAVIORS.include?(value)
147
+ raise ConfigurationError,
148
+ "Unknown Upkeep refused_boundary_behavior #{value.inspect}; expected one of #{REFUSED_BOUNDARY_BEHAVIORS.join(", ")}"
149
+ end
150
+
151
+ @refused_boundary_behavior = value
152
+ end
153
+
154
+ def identify(name, current: nil, session: nil, cookie: nil, warden: nil, &block)
155
+ source, source_key = identity_source(current: current, session: session, cookie: cookie, warden: warden)
156
+ builder = IdentityBuilder.new
157
+ if block
158
+ block.arity == 1 ? block.call(builder) : builder.instance_eval(&block)
159
+ end
160
+
161
+ unless builder.subscribe_block
162
+ raise ConfigurationError, "config.identify :#{name} requires a subscribe block"
163
+ end
164
+
165
+ @identity_definitions[name.to_sym] = IdentityDefinition.new(
166
+ name: name,
167
+ source: source,
168
+ source_key: source_key,
169
+ subscribe_block: builder.subscribe_block,
170
+ absent_block: builder.absent_block
171
+ )
172
+ end
173
+
174
+ def identity_presence_metadata(source:, key:, value:)
175
+ definitions = identity_definitions.select { |definition| definition.matches_source?(source, key) }
176
+ absent_by_name = definitions.to_h do |definition|
177
+ [definition.name.to_s, definition.absent?(value)]
178
+ end
179
+
180
+ {
181
+ partitioning: if definitions.any?
182
+ absent_by_name.values.any? { |absent| !absent }
183
+ else
184
+ !value.nil?
185
+ end,
186
+ absent_by_name: absent_by_name
187
+ }
188
+ end
189
+
190
+ def identity_definitions
191
+ @identity_definitions.values
192
+ end
193
+
194
+ def identity_definition(name)
195
+ @identity_definitions.fetch(name.to_sym)
196
+ end
197
+
198
+ def clear_identities!
199
+ @identity_definitions.clear
200
+ end
201
+
202
+ private
203
+
204
+ def identity_source(current:, session:, cookie:, warden:)
205
+ sources = {
206
+ current: current,
207
+ session: session,
208
+ cookie: cookie,
209
+ warden: warden
210
+ }.compact
211
+
212
+ unless sources.size == 1
213
+ raise ConfigurationError,
214
+ "config.identify requires exactly one source: #{IDENTITY_SOURCES.join(", ")}"
215
+ end
216
+
217
+ source, value = sources.first
218
+ [source, normalize_identity_source(source, value)]
219
+ end
220
+
221
+ def normalize_identity_source(source, value)
222
+ case source
223
+ when :current
224
+ owner, name = Array(value)
225
+ unless owner && name
226
+ raise ConfigurationError,
227
+ "config.identify current: expects [CurrentClass, :attribute]"
228
+ end
229
+
230
+ { owner: owner.to_s, name: name.to_s }
231
+ when :session, :cookie, :warden
232
+ value.to_s
233
+ end
234
+ end
235
+
236
+ def default_refused_boundary_behavior
237
+ if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.to_s == "production"
238
+ :warn
239
+ else
240
+ :raise
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Upkeep
6
+ module Rails
7
+ module ControllerRuntime
8
+ extend ActiveSupport::Concern
9
+
10
+ SUPPRESS_KEY = :upkeep_rails_controller_runtime_suppressed
11
+
12
+ included do
13
+ prepend_around_action :upkeep_capture_request
14
+ end
15
+
16
+ # Public override point for host controllers. Return false to opt a request out
17
+ # of Upkeep capture and live registration: the action still runs and the page
18
+ # renders normally, but no subscription is recorded, no source is injected, and
19
+ # no boundary is analyzed (so an opaque relation on that request neither raises
20
+ # nor warns). Use for render paths Upkeep cannot prove and that should not be
21
+ # made reactive, e.g. full-text search results backed by raw tsvector SQL.
22
+ def upkeep_reactive_request?
23
+ true
24
+ end
25
+
26
+ module_function
27
+
28
+ def install
29
+ return if @installed
30
+ return unless defined?(::ActionController::Base)
31
+
32
+ ::ActionController::Base.include(self)
33
+ @installed = true
34
+ end
35
+
36
+ def installed?
37
+ !!@installed
38
+ end
39
+
40
+ def reset!
41
+ @installed = false
42
+ end
43
+
44
+ def suppress
45
+ previous = Thread.current[SUPPRESS_KEY]
46
+ Thread.current[SUPPRESS_KEY] = true
47
+ yield
48
+ ensure
49
+ Thread.current[SUPPRESS_KEY] = previous
50
+ end
51
+
52
+ def suppressed?
53
+ Thread.current[SUPPRESS_KEY]
54
+ end
55
+
56
+ private
57
+
58
+ def upkeep_capture_request(&action)
59
+ return action.call if ControllerRuntime.suppressed?
60
+ return action.call if Upkeep::Runtime::Observation.recorder
61
+
62
+ payload = {
63
+ controller: self.class.name,
64
+ action: action_name,
65
+ method: request.request_method,
66
+ path: request.fullpath,
67
+ subscription_request: upkeep_subscription_request?
68
+ }
69
+ ActiveSupport::Notifications.instrument(Upkeep::Rails::REQUEST_CAPTURE, payload) do
70
+ upkeep_capture_request_with_timing(action, payload)
71
+ end
72
+ end
73
+
74
+ def upkeep_capture_request_with_timing(action, payload)
75
+ measure_phase(payload, :deliver_pending_ms) { Upkeep::Rails.deliver_changes_now! }
76
+
77
+ result = nil
78
+ capture = nil
79
+ changes = []
80
+ measure_phase(payload, :change_capture_ms) do
81
+ _captured, changes = Upkeep::Runtime::ChangeLog.capture do
82
+ if payload.fetch(:subscription_request)
83
+ capture = Upkeep::Capture::Request.call(self, profile: request_capture_profile?) { action.call }
84
+ result = capture.action_result
85
+ else
86
+ measure_phase(payload, :action_ms) { result = action.call }
87
+ end
88
+ end
89
+ end
90
+ record_capture_payload(payload, capture) if capture
91
+
92
+ measure_phase(payload, :deliver_changes_ms) do
93
+ if capture
94
+ Upkeep::Rails.deliver_changes_now!(changes)
95
+ else
96
+ Upkeep::Rails.deliver_changes!(changes)
97
+ end
98
+ end
99
+
100
+ registration = nil
101
+ if capture
102
+ measure_phase(payload, :register_ms) do
103
+ registration = Upkeep::Rails.register_controller_subscription(self, capture)
104
+ end
105
+ end
106
+ payload[:registered] = !!registration
107
+ if capture && registration
108
+ measure_phase(payload, :inject_ms) do
109
+ response.body = Upkeep::Rails::ClientSubscription.inject(
110
+ capture.html,
111
+ identity: registration.identity,
112
+ subscription: registration.subscription
113
+ )
114
+ end
115
+ payload[:subscription_id] = registration.subscription.id
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ def record_capture_payload(payload, capture)
122
+ payload[:response_status] = capture.response_status
123
+ payload[:response_content_type] = capture.response_content_type
124
+ payload[:response_media_type] = capture.response_media_type
125
+ payload[:html_response] = capture.html_response?
126
+ payload[:response_successful] = capture.successful?
127
+ payload[:html_bytes] = capture.html.bytesize
128
+ payload[:graph_frames] = capture.recorder.graph.frame_nodes.size
129
+ payload[:graph_dependencies] = capture.recorder.graph.dependency_nodes.size
130
+ capture.timings.each do |phase, ms|
131
+ payload[:"capture_#{phase}"] = ms
132
+ end
133
+ capture.counters.each do |counter, value|
134
+ payload[:"capture_#{counter}"] = value
135
+ end
136
+ end
137
+
138
+ def measure_phase(payload, key)
139
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ yield
141
+ ensure
142
+ payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
143
+ end
144
+
145
+ def upkeep_subscription_request?
146
+ upkeep_reactive_request? && (request.get? || request.head?)
147
+ end
148
+
149
+ def request_capture_profile?
150
+ ActiveSupport::Notifications.notifier.listening?(Upkeep::Rails::REQUEST_CAPTURE)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Upkeep
6
+ module Rails
7
+ class DeliveryJob < ::ActiveJob::Base
8
+ queue_as { Upkeep::Rails.configuration.delivery_queue }
9
+
10
+ def perform(changes)
11
+ Upkeep::Rails.deliver_changes_now!(normalize_changes(changes))
12
+ end
13
+
14
+ private
15
+
16
+ def normalize_changes(changes)
17
+ Array(changes).map { |change| normalize_change(change) }
18
+ end
19
+
20
+ def normalize_change(change)
21
+ return change unless change.respond_to?(:to_h)
22
+
23
+ change.to_h.transform_keys do |key|
24
+ key.respond_to?(:to_sym) ? key.to_sym : key
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ module Install
6
+ module_function
7
+
8
+ def call
9
+ return unless Upkeep::Rails.configuration.enabled
10
+ return if @installed
11
+
12
+ Runtime::Install.call if defined?(::ActiveRecord::Base)
13
+ ActionViewCapture.install if defined?(::ActionView::Template)
14
+ ControllerRuntime.install if defined?(::ActionController::Base)
15
+
16
+ @installed = true
17
+ end
18
+
19
+ def installed?
20
+ !!@installed
21
+ end
22
+
23
+ def reset!
24
+ @installed = false
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ config.upkeep = ActiveSupport::OrderedOptions.new
7
+
8
+ initializer "upkeep_rails.configure" do |app|
9
+ Upkeep::Rails.configure do |config|
10
+ config.enabled = app.config.upkeep.fetch(:enabled, true)
11
+ config.subscription_store = app.config.upkeep.fetch(:subscription_store, config.subscription_store)
12
+ config.delivery_adapter = app.config.upkeep.fetch(:delivery_adapter, Railtie.default_delivery_adapter(app))
13
+ config.delivery_queue = app.config.upkeep.fetch(:delivery_queue, config.delivery_queue)
14
+ config.delivery_batch_window =
15
+ app.config.upkeep.fetch(:delivery_batch_window, config.delivery_batch_window)
16
+ config.activation_token_expires_in =
17
+ app.config.upkeep.fetch(:activation_token_expires_in, config.activation_token_expires_in)
18
+ config.refused_boundary_behavior =
19
+ app.config.upkeep.fetch(:refused_boundary_behavior, config.refused_boundary_behavior)
20
+ end
21
+ end
22
+
23
+ initializer "upkeep_rails.install" do
24
+ ActiveSupport.on_load(:active_record) { Upkeep::Rails::Install.call }
25
+ ActiveSupport.on_load(:action_controller_base) { Upkeep::Rails::Install.call }
26
+ ActiveSupport.on_load(:action_view) { Upkeep::Rails::Install.call }
27
+ end
28
+
29
+ initializer "upkeep_rails.validate_configuration", after: "upkeep_rails.install" do |app|
30
+ app.config.after_initialize do
31
+ Upkeep::Rails.validate_configuration! unless Railtie.rake_task?
32
+ end
33
+ end
34
+
35
+ def self.rake_task?
36
+ defined?(::Rake) &&
37
+ ::Rake.respond_to?(:application) &&
38
+ ::Rake.application.top_level_tasks.any?
39
+ end
40
+
41
+ def self.default_delivery_adapter(app)
42
+ if app.respond_to?(:env) && app.env.to_s == "production"
43
+ :active_job
44
+ else
45
+ Upkeep::Rails.configuration.delivery_adapter
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+ require "active_support/hash_with_indifferent_access"
5
+ require "stringio"
6
+
7
+ module Upkeep
8
+ module Rails
9
+ module Replay
10
+ module_function
11
+
12
+ class RackSession < ActiveSupport::HashWithIndifferentAccess
13
+ def enabled? = true
14
+
15
+ def loaded? = true
16
+
17
+ def id = self[:session_id]
18
+
19
+ def id_was = id
20
+ end
21
+
22
+ def render(recipe)
23
+ replay = recipe.replay
24
+
25
+ case replay
26
+ when ::Upkeep::Replay::ControllerPage
27
+ render_controller_page(replay)
28
+ when ::Upkeep::Replay::Template
29
+ render_template(replay)
30
+ when ::Upkeep::Replay::Fragment
31
+ render_fragment(replay)
32
+ when ::Upkeep::Replay::Collection
33
+ render_collection(replay)
34
+ when ::Upkeep::Replay::CollectionMember
35
+ render_collection_member(replay)
36
+ else
37
+ raise "unknown Rails replay recipe type: #{replay.class.name}"
38
+ end
39
+ end
40
+
41
+ def render_controller_page(replay)
42
+ controller = constantize(replay.controller_class)
43
+ _status, _headers, body = ControllerRuntime.suppress do
44
+ controller.action(replay.action).call(rack_env(replay.env))
45
+ end
46
+ collect_response_body(body)
47
+ end
48
+
49
+ def render_template(replay)
50
+ template_context = replay_template_context
51
+ renderer_for(replay).render(
52
+ template: replay.template,
53
+ locals: revive_hash(replay.locals, template: template_context),
54
+ assigns: revive_hash(replay.assigns, template: template_context)
55
+ )
56
+ end
57
+
58
+ def render_fragment(replay)
59
+ template_context = replay_template_context
60
+ renderer_for(replay).render(
61
+ partial: partial_path(replay.template),
62
+ locals: revive_hash(replay.locals, template: template_context),
63
+ assigns: revive_hash(replay.assigns, template: template_context)
64
+ )
65
+ end
66
+
67
+ def render_collection(replay)
68
+ template_context = replay_template_context
69
+ options = revive_hash(replay.options, template: template_context)
70
+ collection = revive_value(replay.collection, template: template_context)
71
+
72
+ if replay.derived_partial?
73
+ renderer_for(replay).render(collection)
74
+ else
75
+ renderer_for(replay).render(options.merge(
76
+ partial: replay.partial,
77
+ collection: collection
78
+ ))
79
+ end
80
+ end
81
+
82
+ def render_collection_member(replay)
83
+ template_context = replay_template_context
84
+ options = revive_hash(replay.options, template: template_context)
85
+ record = revive_value(replay.record, template: template_context)
86
+ locals = options.fetch(:locals, {})
87
+ local_name = (options[:as] || inferred_local_name(replay.partial)).to_sym
88
+
89
+ renderer_for(replay).render(
90
+ partial: replay.partial,
91
+ locals: locals.merge(local_name => record)
92
+ )
93
+ end
94
+
95
+ def renderer_for(replay)
96
+ if replay.controller_class
97
+ constantize(replay.controller_class).renderer
98
+ else
99
+ ::ActionController::Base.renderer
100
+ end
101
+ end
102
+
103
+ def rack_env(env)
104
+ env = env.each_with_object({}) { |(key, value), copy| copy[key.to_s] = revive_env_value(value) }
105
+ env["rack.input"] = StringIO.new
106
+ env["rack.errors"] ||= StringIO.new
107
+ env
108
+ end
109
+
110
+ def revive_hash(values, template: replay_template_context)
111
+ values = values.entries if values.is_a?(::Upkeep::Replay::HashValue)
112
+
113
+ values.each_with_object({}) do |(key, value), revived|
114
+ revived[key.to_sym] = revive_value(value, template: template)
115
+ end
116
+ end
117
+
118
+ def revive_value(snapshot, template: replay_template_context)
119
+ snapshot = ::Upkeep::Replay.value(snapshot)
120
+
121
+ case snapshot
122
+ when ::Upkeep::Replay::ActiveRecordValue
123
+ constantize(snapshot.model).find(snapshot.id)
124
+ when ::Upkeep::Replay::ActiveRecordRelationValue
125
+ constantize(snapshot.model).find_by_sql(snapshot.sql)
126
+ when ::Upkeep::Replay::ArrayValue
127
+ snapshot.items.map { |item| revive_value(item, template: template) }
128
+ when ::Upkeep::Replay::HashValue
129
+ revive_hash(snapshot.entries, template: template)
130
+ when ::Upkeep::Replay::LiteralValue
131
+ snapshot.value
132
+ when ::Upkeep::Replay::RailsFormBuilderValue
133
+ revive_form_builder(snapshot, template: template)
134
+ else
135
+ raise "unsupported Rails replay value type: #{snapshot.class.name}"
136
+ end
137
+ end
138
+
139
+ def revive_form_builder(snapshot, template:)
140
+ constantize(snapshot.builder_class).new(
141
+ snapshot.object_name,
142
+ revive_value(snapshot.object, template: template),
143
+ template,
144
+ revive_hash(snapshot.options, template: template)
145
+ )
146
+ end
147
+
148
+ def replay_template_context
149
+ ::ActionView::Base.empty
150
+ end
151
+
152
+ def revive_env_value(value)
153
+ return revive_replay_session(value) if replay_session_snapshot?(value)
154
+
155
+ case value
156
+ when Hash
157
+ value.transform_values { |nested_value| revive_env_value(nested_value) }
158
+ when Array
159
+ value.map { |nested_value| revive_env_value(nested_value) }
160
+ else
161
+ value
162
+ end
163
+ end
164
+
165
+ def replay_session_snapshot?(value)
166
+ return false unless value.is_a?(Hash)
167
+
168
+ type = value["__upkeep_replay_type"] || value[:__upkeep_replay_type]
169
+ type == "rack_session"
170
+ end
171
+
172
+ def revive_replay_session(snapshot)
173
+ values = snapshot["values"] || snapshot[:values] || {}
174
+ RackSession.new(revive_env_value(values))
175
+ end
176
+
177
+ def partial_path(template)
178
+ template.to_s.sub(%r{(^|/)_([^/]+)\z}, "\\1\\2")
179
+ end
180
+
181
+ def inferred_local_name(partial)
182
+ File.basename(partial.to_s).sub(/\A_/, "").to_sym
183
+ end
184
+
185
+ def constantize(name)
186
+ name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
187
+ end
188
+
189
+ def collect_response_body(body)
190
+ body.each.to_a.join
191
+ ensure
192
+ body.close if body.respond_to?(:close)
193
+ end
194
+
195
+ end
196
+ end
197
+ end