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