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,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,137 @@
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
+ module_function
17
+
18
+ def install
19
+ return if @installed
20
+ return unless defined?(::ActionController::Base)
21
+
22
+ ::ActionController::Base.include(self)
23
+ @installed = true
24
+ end
25
+
26
+ def installed?
27
+ !!@installed
28
+ end
29
+
30
+ def reset!
31
+ @installed = false
32
+ end
33
+
34
+ def suppress
35
+ previous = Thread.current[SUPPRESS_KEY]
36
+ Thread.current[SUPPRESS_KEY] = true
37
+ yield
38
+ ensure
39
+ Thread.current[SUPPRESS_KEY] = previous
40
+ end
41
+
42
+ def suppressed?
43
+ Thread.current[SUPPRESS_KEY]
44
+ end
45
+
46
+ private
47
+
48
+ def upkeep_capture_request(&action)
49
+ return action.call if ControllerRuntime.suppressed?
50
+ return action.call if Upkeep::Runtime::Observation.recorder
51
+
52
+ payload = {
53
+ controller: self.class.name,
54
+ action: action_name,
55
+ method: request.request_method,
56
+ path: request.fullpath,
57
+ subscription_request: upkeep_subscription_request?
58
+ }
59
+ ActiveSupport::Notifications.instrument(Upkeep::Rails::REQUEST_CAPTURE, payload) do
60
+ upkeep_capture_request_with_timing(action, payload)
61
+ end
62
+ end
63
+
64
+ def upkeep_capture_request_with_timing(action, payload)
65
+ measure_phase(payload, :deliver_pending_ms) { Upkeep::Rails.deliver_changes_now! }
66
+
67
+ result = nil
68
+ capture = nil
69
+ changes = []
70
+ measure_phase(payload, :change_capture_ms) do
71
+ _captured, changes = Upkeep::Runtime::ChangeLog.capture do
72
+ if payload.fetch(:subscription_request)
73
+ capture = Upkeep::Capture::Request.call(self, profile: request_capture_profile?) { action.call }
74
+ result = capture.action_result
75
+ else
76
+ measure_phase(payload, :action_ms) { result = action.call }
77
+ end
78
+ end
79
+ end
80
+ record_capture_payload(payload, capture) if capture
81
+
82
+ registration = nil
83
+ if capture
84
+ measure_phase(payload, :register_ms) do
85
+ registration = Upkeep::Rails.register_controller_subscription(self, capture)
86
+ end
87
+ end
88
+ payload[:registered] = !!registration
89
+ if capture && registration
90
+ measure_phase(payload, :inject_ms) do
91
+ response.body = Upkeep::Rails::ClientSubscription.inject(
92
+ capture.html,
93
+ identity: registration.identity,
94
+ subscription: registration.subscription
95
+ )
96
+ end
97
+ payload[:subscription_id] = registration.subscription.id
98
+ end
99
+ measure_phase(payload, :deliver_changes_ms) { Upkeep::Rails.deliver_changes!(changes) }
100
+
101
+ result
102
+ end
103
+
104
+ def record_capture_payload(payload, capture)
105
+ payload[:response_status] = capture.response_status
106
+ payload[:response_content_type] = capture.response_content_type
107
+ payload[:response_media_type] = capture.response_media_type
108
+ payload[:html_response] = capture.html_response?
109
+ payload[:response_successful] = capture.successful?
110
+ payload[:html_bytes] = capture.html.bytesize
111
+ payload[:graph_frames] = capture.recorder.graph.frame_nodes.size
112
+ payload[:graph_dependencies] = capture.recorder.graph.dependency_nodes.size
113
+ capture.timings.each do |phase, ms|
114
+ payload[:"capture_#{phase}"] = ms
115
+ end
116
+ capture.counters.each do |counter, value|
117
+ payload[:"capture_#{counter}"] = value
118
+ end
119
+ end
120
+
121
+ def measure_phase(payload, key)
122
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ yield
124
+ ensure
125
+ payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
126
+ end
127
+
128
+ def upkeep_subscription_request?
129
+ request.get? || request.head?
130
+ end
131
+
132
+ def request_capture_profile?
133
+ ActiveSupport::Notifications.notifier.listening?(Upkeep::Rails::REQUEST_CAPTURE)
134
+ end
135
+ end
136
+ end
137
+ 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,176 @@
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
+ renderer_for(replay).render(
51
+ template: replay.template,
52
+ locals: revive_hash(replay.locals)
53
+ )
54
+ end
55
+
56
+ def render_fragment(replay)
57
+ renderer_for(replay).render(
58
+ partial: partial_path(replay.template),
59
+ locals: revive_hash(replay.locals)
60
+ )
61
+ end
62
+
63
+ def render_collection(replay)
64
+ options = revive_hash(replay.options)
65
+ collection = revive_value(replay.collection)
66
+
67
+ if replay.derived_partial?
68
+ renderer_for(replay).render(collection)
69
+ else
70
+ renderer_for(replay).render(options.merge(
71
+ partial: replay.partial,
72
+ collection: collection
73
+ ))
74
+ end
75
+ end
76
+
77
+ def render_collection_member(replay)
78
+ options = revive_hash(replay.options)
79
+ record = revive_value(replay.record)
80
+ locals = options.fetch(:locals, {})
81
+ local_name = (options[:as] || inferred_local_name(replay.partial)).to_sym
82
+
83
+ renderer_for(replay).render(
84
+ partial: replay.partial,
85
+ locals: locals.merge(local_name => record)
86
+ )
87
+ end
88
+
89
+ def renderer_for(replay)
90
+ if replay.controller_class
91
+ constantize(replay.controller_class).renderer
92
+ else
93
+ ::ActionController::Base.renderer
94
+ end
95
+ end
96
+
97
+ def rack_env(env)
98
+ env = env.each_with_object({}) { |(key, value), copy| copy[key.to_s] = revive_env_value(value) }
99
+ env["rack.input"] = StringIO.new
100
+ env["rack.errors"] ||= StringIO.new
101
+ env
102
+ end
103
+
104
+ def revive_hash(values)
105
+ values = values.entries if values.is_a?(::Upkeep::Replay::HashValue)
106
+
107
+ values.each_with_object({}) do |(key, value), revived|
108
+ revived[key.to_sym] = revive_value(value)
109
+ end
110
+ end
111
+
112
+ def revive_value(snapshot)
113
+ snapshot = ::Upkeep::Replay.value(snapshot)
114
+
115
+ case snapshot
116
+ when ::Upkeep::Replay::ActiveRecordValue
117
+ constantize(snapshot.model).find(snapshot.id)
118
+ when ::Upkeep::Replay::ActiveRecordRelationValue
119
+ constantize(snapshot.model).find_by_sql(snapshot.sql)
120
+ when ::Upkeep::Replay::ArrayValue
121
+ snapshot.items.map { |item| revive_value(item) }
122
+ when ::Upkeep::Replay::HashValue
123
+ revive_hash(snapshot.entries)
124
+ when ::Upkeep::Replay::LiteralValue
125
+ snapshot.value
126
+ else
127
+ raise "unsupported Rails replay value type: #{snapshot.class.name}"
128
+ end
129
+ end
130
+
131
+ def revive_env_value(value)
132
+ return revive_replay_session(value) if replay_session_snapshot?(value)
133
+
134
+ case value
135
+ when Hash
136
+ value.transform_values { |nested_value| revive_env_value(nested_value) }
137
+ when Array
138
+ value.map { |nested_value| revive_env_value(nested_value) }
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def replay_session_snapshot?(value)
145
+ return false unless value.is_a?(Hash)
146
+
147
+ type = value["__upkeep_replay_type"] || value[:__upkeep_replay_type]
148
+ type == "rack_session"
149
+ end
150
+
151
+ def revive_replay_session(snapshot)
152
+ values = snapshot["values"] || snapshot[:values] || {}
153
+ RackSession.new(revive_env_value(values))
154
+ end
155
+
156
+ def partial_path(template)
157
+ template.to_s.sub(%r{(^|/)_([^/]+)\z}, "\\1\\2")
158
+ end
159
+
160
+ def inferred_local_name(partial)
161
+ File.basename(partial.to_s).sub(/\A_/, "").to_sym
162
+ end
163
+
164
+ def constantize(name)
165
+ name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
166
+ end
167
+
168
+ def collect_response_body(body)
169
+ body.each.to_a.join
170
+ ensure
171
+ body.close if body.respond_to?(:close)
172
+ end
173
+
174
+ end
175
+ end
176
+ end