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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +518 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +514 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +821 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- 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
|