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,514 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "herb"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module HerbSupport
|
|
8
|
+
class TemplateManifest
|
|
9
|
+
DEFAULT_PARSE_OPTIONS = {
|
|
10
|
+
strict: true,
|
|
11
|
+
track_whitespace: true,
|
|
12
|
+
render_nodes: true,
|
|
13
|
+
action_view_helpers: true,
|
|
14
|
+
transform_conditionals: true
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
EMPTY_ROOT_SHAPE = {
|
|
18
|
+
significant_children: 0,
|
|
19
|
+
root_elements: 0,
|
|
20
|
+
single_root: false,
|
|
21
|
+
multi_root: false
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :path, :parse, :root_shape, :frontend_tag_plan, :render_nodes, :helper_lowered_elements, :recovery
|
|
25
|
+
|
|
26
|
+
def self.build(path:, source:, parse_options: DEFAULT_PARSE_OPTIONS)
|
|
27
|
+
parse_result = ::Herb.parse(source, **parse_options)
|
|
28
|
+
parse = parse_status(parse_result, mode: parse_mode(parse_options))
|
|
29
|
+
|
|
30
|
+
if parse.fetch(:ok)
|
|
31
|
+
visitor = Visitor.new(path: path, source: source)
|
|
32
|
+
parse_result.value&.accept(visitor)
|
|
33
|
+
|
|
34
|
+
return new(
|
|
35
|
+
path: path,
|
|
36
|
+
parse: parse,
|
|
37
|
+
root_shape: visitor.root_shape,
|
|
38
|
+
frontend_tag_plan: visitor.frontend_tag_plan,
|
|
39
|
+
render_nodes: visitor.render_nodes,
|
|
40
|
+
helper_lowered_elements: visitor.helper_lowered_elements,
|
|
41
|
+
recovery: nil
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
parse, recovery = recovery_for(path: path, source: source, parse: parse, parse_options: parse_options)
|
|
46
|
+
|
|
47
|
+
new(
|
|
48
|
+
path: path,
|
|
49
|
+
parse: parse,
|
|
50
|
+
root_shape: EMPTY_ROOT_SHAPE,
|
|
51
|
+
frontend_tag_plan: [],
|
|
52
|
+
render_nodes: [],
|
|
53
|
+
helper_lowered_elements: [],
|
|
54
|
+
recovery: recovery
|
|
55
|
+
)
|
|
56
|
+
rescue StandardError => error
|
|
57
|
+
new(
|
|
58
|
+
path: path,
|
|
59
|
+
parse: {
|
|
60
|
+
ok: false,
|
|
61
|
+
mode: parse_mode(parse_options),
|
|
62
|
+
exception: error.class.name,
|
|
63
|
+
message: error.message,
|
|
64
|
+
recovered: false
|
|
65
|
+
},
|
|
66
|
+
root_shape: EMPTY_ROOT_SHAPE,
|
|
67
|
+
frontend_tag_plan: [],
|
|
68
|
+
render_nodes: [],
|
|
69
|
+
helper_lowered_elements: [],
|
|
70
|
+
recovery: nil
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.summary(manifests)
|
|
75
|
+
partials = manifests.select(&:partial?)
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
templates_scanned: manifests.size,
|
|
79
|
+
strict_parse_failures: manifests.count { |manifest| !manifest.parse.fetch(:ok) },
|
|
80
|
+
recoverable_parse_failures: manifests.count(&:recovered?),
|
|
81
|
+
recovered_render_nodes: manifests.sum { |manifest| manifest.recovery_render_nodes.size },
|
|
82
|
+
render_nodes: manifests.sum { |manifest| manifest.render_nodes.size },
|
|
83
|
+
helper_lowered_elements: manifests.sum { |manifest| manifest.helper_lowered_elements.size },
|
|
84
|
+
frontend_tag_targets: manifests.sum { |manifest| manifest.frontend_tag_plan.size },
|
|
85
|
+
page_root_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "page_root" } },
|
|
86
|
+
fragment_root_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "fragment_root" } },
|
|
87
|
+
render_site_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "render_site" } },
|
|
88
|
+
partials: partials.size,
|
|
89
|
+
single_root_partials: partials.count { |manifest| manifest.root_shape.fetch(:single_root, false) },
|
|
90
|
+
multi_root_partials: partials.count { |manifest| manifest.root_shape.fetch(:multi_root, false) }
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.parse_status(parse_result, mode:)
|
|
95
|
+
errors = parse_result.errors.map { |error| error_payload(error) }
|
|
96
|
+
warnings = parse_result.warnings.map { |warning| error_payload(warning) }
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
ok: errors.empty?,
|
|
100
|
+
mode: mode,
|
|
101
|
+
errors: errors,
|
|
102
|
+
warnings: warnings,
|
|
103
|
+
recovered: false
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
private_class_method :parse_status
|
|
107
|
+
|
|
108
|
+
def self.recovery_for(path:, source:, parse:, parse_options:)
|
|
109
|
+
return [parse, nil] unless parse_options.fetch(:strict, false)
|
|
110
|
+
|
|
111
|
+
recovery_options = parse_options.merge(strict: false)
|
|
112
|
+
recovery_result = ::Herb.parse(source, **recovery_options)
|
|
113
|
+
recovery_parse = parse_status(recovery_result, mode: "non_strict")
|
|
114
|
+
parse = parse.merge(recovered: recovery_parse.fetch(:ok), recovery: recovery_parse)
|
|
115
|
+
|
|
116
|
+
return [parse, { parse: recovery_parse }] unless recovery_parse.fetch(:ok)
|
|
117
|
+
|
|
118
|
+
visitor = Visitor.new(path: path, source: source)
|
|
119
|
+
recovery_result.value&.accept(visitor)
|
|
120
|
+
|
|
121
|
+
[
|
|
122
|
+
parse,
|
|
123
|
+
{
|
|
124
|
+
parse: recovery_parse,
|
|
125
|
+
root_shape: visitor.root_shape,
|
|
126
|
+
frontend_tag_plan: visitor.frontend_tag_plan,
|
|
127
|
+
render_nodes: visitor.render_nodes,
|
|
128
|
+
helper_lowered_elements: visitor.helper_lowered_elements
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
end
|
|
132
|
+
private_class_method :recovery_for
|
|
133
|
+
|
|
134
|
+
def self.parse_mode(parse_options)
|
|
135
|
+
parse_options.fetch(:strict, false) ? "strict" : "non_strict"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :parse_mode
|
|
138
|
+
|
|
139
|
+
def self.error_payload(error)
|
|
140
|
+
{
|
|
141
|
+
class: error.class.name,
|
|
142
|
+
message: error.respond_to?(:message) ? error.message : error.inspect,
|
|
143
|
+
location: error.respond_to?(:location) ? location_payload(error.location) : nil
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
private_class_method :error_payload
|
|
147
|
+
|
|
148
|
+
def self.location_payload(location)
|
|
149
|
+
return nil unless location
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
start: {
|
|
153
|
+
line: location.start.line,
|
|
154
|
+
column: location.start.column
|
|
155
|
+
},
|
|
156
|
+
end: {
|
|
157
|
+
line: location.end.line,
|
|
158
|
+
column: location.end.column
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
private_class_method :location_payload
|
|
163
|
+
|
|
164
|
+
def initialize(path:, parse:, root_shape:, frontend_tag_plan:, render_nodes:, helper_lowered_elements:, recovery:)
|
|
165
|
+
@path = path
|
|
166
|
+
@parse = parse
|
|
167
|
+
@root_shape = root_shape
|
|
168
|
+
@frontend_tag_plan = frontend_tag_plan
|
|
169
|
+
@render_nodes = render_nodes
|
|
170
|
+
@helper_lowered_elements = helper_lowered_elements
|
|
171
|
+
@recovery = recovery
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def to_h
|
|
175
|
+
{
|
|
176
|
+
path: path,
|
|
177
|
+
parse: parse,
|
|
178
|
+
root_shape: root_shape,
|
|
179
|
+
frontend_tag_plan: frontend_tag_plan,
|
|
180
|
+
render_nodes: render_nodes,
|
|
181
|
+
helper_lowered_elements: helper_lowered_elements,
|
|
182
|
+
recovery: recovery
|
|
183
|
+
}.compact
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fingerprint
|
|
187
|
+
@fingerprint ||= Digest::SHA256.hexdigest(to_h.inspect)[0, 16]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def partial?
|
|
191
|
+
File.basename(path).start_with?("_")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def recovered?
|
|
195
|
+
parse.fetch(:recovered, false)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def recovery_frontend_tag_plan
|
|
199
|
+
Array(recovery&.fetch(:frontend_tag_plan, []))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def recovery_render_nodes
|
|
203
|
+
Array(recovery&.fetch(:render_nodes, []))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
class Visitor < ::Herb::Visitor
|
|
207
|
+
attr_reader :frontend_tag_plan, :render_nodes, :helper_lowered_elements
|
|
208
|
+
|
|
209
|
+
def initialize(path:, source:)
|
|
210
|
+
super()
|
|
211
|
+
@path = path
|
|
212
|
+
@frontend_tag_plan = []
|
|
213
|
+
@render_nodes = []
|
|
214
|
+
@helper_lowered_elements = []
|
|
215
|
+
@html_stack = []
|
|
216
|
+
@root_shape = nil
|
|
217
|
+
@line_offsets = build_line_offsets(source)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def root_shape
|
|
221
|
+
@root_shape || EMPTY_ROOT_SHAPE
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def visit_document_node(node)
|
|
225
|
+
significant_children = node.children.reject { |child| insignificant_document_child?(child) }
|
|
226
|
+
root_elements = significant_children.select { |child| html_element?(child) }
|
|
227
|
+
|
|
228
|
+
@root_shape = {
|
|
229
|
+
significant_children: significant_children.size,
|
|
230
|
+
root_elements: root_elements.size,
|
|
231
|
+
root_types: significant_children.map { |child| child.class.name },
|
|
232
|
+
single_root: significant_children.size == 1 && root_elements.size == 1,
|
|
233
|
+
single_root_element: root_elements.size == 1,
|
|
234
|
+
multi_root: root_elements.size > 1
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if partial_template?
|
|
238
|
+
plan_fragment_root_tag(root_elements.first) if root_shape.fetch(:single_root)
|
|
239
|
+
elsif root_shape.fetch(:single_root_element)
|
|
240
|
+
# A page template's recorded frame target needs a matching DOM marker so it can be
|
|
241
|
+
# resolved during full-page rerender. Page templates routinely carry non-element
|
|
242
|
+
# siblings such as `content_for`/`yield :head` ERB statements that produce no enclosing
|
|
243
|
+
# DOM, so anchor the page-frame marker to the single root element rather than requiring
|
|
244
|
+
# the element to be the only significant child.
|
|
245
|
+
plan_page_root_tag(root_elements.first)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
super
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def visit_erb_render_node(node)
|
|
252
|
+
keywords = node.keywords
|
|
253
|
+
render_node = render_node_payload(node, keywords)
|
|
254
|
+
|
|
255
|
+
@render_nodes << render_node
|
|
256
|
+
@frontend_tag_plan << render_site_tag(render_node) if render_node.fetch(:render_site_container)
|
|
257
|
+
|
|
258
|
+
super
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def visit_html_element_node(node)
|
|
262
|
+
if node.respond_to?(:element_source) && node.element_source && node.element_source != "HTML"
|
|
263
|
+
@helper_lowered_elements << {
|
|
264
|
+
location: location_payload(node.location),
|
|
265
|
+
tag_name: token_value(node.tag_name),
|
|
266
|
+
element_source: node.element_source
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
@html_stack.push(node)
|
|
271
|
+
begin
|
|
272
|
+
super
|
|
273
|
+
ensure
|
|
274
|
+
@html_stack.pop
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
attr_reader :path, :line_offsets, :html_stack
|
|
281
|
+
|
|
282
|
+
def build_line_offsets(source)
|
|
283
|
+
offsets = [0]
|
|
284
|
+
source.each_line(chomp: false).with_index do |line, index|
|
|
285
|
+
offsets[index + 1] = offsets[index] + line.bytesize
|
|
286
|
+
end
|
|
287
|
+
offsets
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def offset_for(position)
|
|
291
|
+
line_offsets.fetch(position.line - 1) + position.column
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def render_node_payload(node, keywords)
|
|
295
|
+
{
|
|
296
|
+
location: location_payload(node.location),
|
|
297
|
+
site_id: site_id("render", node.location),
|
|
298
|
+
expression: token_value(node.content)&.strip,
|
|
299
|
+
start_offset: offset_for(node.location.start),
|
|
300
|
+
end_offset: offset_for(node.location.end),
|
|
301
|
+
kind: render_kind(keywords),
|
|
302
|
+
partial: token_value(keywords&.partial),
|
|
303
|
+
template_path: token_value(keywords&.template_path),
|
|
304
|
+
layout: token_value(keywords&.layout),
|
|
305
|
+
collection: token_value(keywords&.collection),
|
|
306
|
+
object: token_value(keywords&.object),
|
|
307
|
+
as: token_value(keywords&.as_name),
|
|
308
|
+
locals: Array(keywords&.locals).map { |local| token_value(local.name) },
|
|
309
|
+
block_arguments: Array(node.block_arguments).map { |argument| token_value(argument.name) },
|
|
310
|
+
render_site_container: render_site_container_for(node, keywords),
|
|
311
|
+
render_site_source: render_site_source(keywords)
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def plan_fragment_root_tag(root_element)
|
|
316
|
+
@frontend_tag_plan << {
|
|
317
|
+
kind: "fragment_root",
|
|
318
|
+
target: "root_element",
|
|
319
|
+
location: location_payload(root_element.location),
|
|
320
|
+
tag_name: token_value(root_element.tag_name),
|
|
321
|
+
element_source: element_source(root_element),
|
|
322
|
+
attributes: [
|
|
323
|
+
{
|
|
324
|
+
name: "data-upkeep-frame",
|
|
325
|
+
value: "<%= upkeep_frame_id %>"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "data-upkeep-template",
|
|
329
|
+
value: template_id
|
|
330
|
+
}
|
|
331
|
+
],
|
|
332
|
+
update_role: "morph or replace this rendered fragment when runtime observations match a committed change"
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def plan_page_root_tag(root_element)
|
|
337
|
+
@frontend_tag_plan << {
|
|
338
|
+
kind: "page_root",
|
|
339
|
+
target: "root_element",
|
|
340
|
+
location: location_payload(root_element.location),
|
|
341
|
+
tag_name: token_value(root_element.tag_name),
|
|
342
|
+
element_source: element_source(root_element),
|
|
343
|
+
attributes: [
|
|
344
|
+
{
|
|
345
|
+
name: "data-upkeep-page-frame",
|
|
346
|
+
value: "<%= upkeep_page_frame_id %>"
|
|
347
|
+
}
|
|
348
|
+
],
|
|
349
|
+
update_role: "replace this rendered page when no narrower frame can safely cover a change"
|
|
350
|
+
}
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def render_site_tag(render_node)
|
|
354
|
+
container = render_node.fetch(:render_site_container)
|
|
355
|
+
|
|
356
|
+
{
|
|
357
|
+
kind: "render_site",
|
|
358
|
+
target: "container_element",
|
|
359
|
+
location: container.fetch(:location),
|
|
360
|
+
tag_name: container.fetch(:tag_name),
|
|
361
|
+
element_source: container.fetch(:element_source),
|
|
362
|
+
site_id: render_node.fetch(:site_id),
|
|
363
|
+
attributes: [
|
|
364
|
+
{
|
|
365
|
+
name: "data-upkeep-render-site",
|
|
366
|
+
value: render_node.fetch(:site_id)
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
render: {
|
|
370
|
+
kind: render_node.fetch(:kind),
|
|
371
|
+
partial: render_node.fetch(:partial),
|
|
372
|
+
collection: render_node.fetch(:collection),
|
|
373
|
+
object: render_node.fetch(:object),
|
|
374
|
+
as: render_node.fetch(:as),
|
|
375
|
+
source: render_node.fetch(:render_site_source)
|
|
376
|
+
},
|
|
377
|
+
update_role: render_site_update_role(render_node)
|
|
378
|
+
}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def render_site_update_role(render_node)
|
|
382
|
+
case render_node.fetch(:render_site_source)
|
|
383
|
+
when "collection_keyword"
|
|
384
|
+
"anchor collection membership while child partial roots carry per-record frame tags"
|
|
385
|
+
when "object_shorthand"
|
|
386
|
+
"anchor a render-object shorthand when ActionView runtime confirms it rendered a collection"
|
|
387
|
+
else
|
|
388
|
+
"record where ActionView runtime must confirm the rendered frame target"
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def render_site_container_for(node, keywords)
|
|
393
|
+
source = render_site_source(keywords)
|
|
394
|
+
return unless source
|
|
395
|
+
|
|
396
|
+
container = html_stack.last
|
|
397
|
+
return unless container
|
|
398
|
+
return unless safe_collection_container?(container, node)
|
|
399
|
+
|
|
400
|
+
{
|
|
401
|
+
location: location_payload(container.location),
|
|
402
|
+
tag_name: token_value(container.tag_name),
|
|
403
|
+
element_source: element_source(container),
|
|
404
|
+
source: source
|
|
405
|
+
}
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def render_site_source(keywords)
|
|
409
|
+
return "collection_keyword" if token_value(keywords&.collection)
|
|
410
|
+
|
|
411
|
+
# ActionView accepts `render @records` / `render records` as a
|
|
412
|
+
# polymorphic render. Herb can identify the stable DOM container, but
|
|
413
|
+
# Ruby decides at runtime whether the object is a collection, a single
|
|
414
|
+
# record, or a renderable component. Mark it as a candidate only; the
|
|
415
|
+
# runtime collection renderer must still confirm a collection before a
|
|
416
|
+
# render-site frame is recorded.
|
|
417
|
+
return "object_shorthand" if token_value(keywords&.object)
|
|
418
|
+
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def safe_collection_container?(container, render_node)
|
|
423
|
+
children = meaningful_children(container)
|
|
424
|
+
children.one? && children.first.equal?(render_node)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def meaningful_children(node)
|
|
428
|
+
Array(node.body).reject { |child| insignificant_document_child?(child) }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def render_kind(keywords)
|
|
432
|
+
return "unknown" unless keywords
|
|
433
|
+
|
|
434
|
+
%i[
|
|
435
|
+
partial template_path layout file inline_template body plain html
|
|
436
|
+
renderable collection object
|
|
437
|
+
].find { |name| token_value(keywords.public_send(name)) }&.to_s || "unknown"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def insignificant_document_child?(node)
|
|
441
|
+
return true if html_text?(node) && token_value(node.content).to_s.strip.empty?
|
|
442
|
+
return true if html_doctype?(node)
|
|
443
|
+
return true if erb_comment?(node)
|
|
444
|
+
|
|
445
|
+
false
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def html_text?(node)
|
|
449
|
+
node.class.name == "Herb::AST::HTMLTextNode"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def html_element?(node)
|
|
453
|
+
[
|
|
454
|
+
"Herb::AST::HTMLElementNode",
|
|
455
|
+
"Herb::AST::HTMLConditionalElementNode"
|
|
456
|
+
].include?(node.class.name)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def element_source(node)
|
|
460
|
+
node.element_source
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def html_doctype?(node)
|
|
464
|
+
node.class.name == "Herb::AST::HTMLDoctypeNode"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def erb_comment?(node)
|
|
468
|
+
node.respond_to?(:tag_opening) && token_value(node.tag_opening).to_s.start_with?("<%#")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def partial_template?
|
|
472
|
+
File.basename(path).start_with?("_")
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def template_id
|
|
476
|
+
Digest::SHA256.hexdigest(path)[0, 16]
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def site_id(kind, location)
|
|
480
|
+
Digest::SHA256.hexdigest([
|
|
481
|
+
path,
|
|
482
|
+
kind,
|
|
483
|
+
location&.start&.line,
|
|
484
|
+
location&.start&.column,
|
|
485
|
+
location&.end&.line,
|
|
486
|
+
location&.end&.column
|
|
487
|
+
].join(":"))[0, 16]
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def token_value(token)
|
|
491
|
+
return nil unless token
|
|
492
|
+
return token.value if token.respond_to?(:value)
|
|
493
|
+
|
|
494
|
+
token.to_s
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def location_payload(location)
|
|
498
|
+
return nil unless location
|
|
499
|
+
|
|
500
|
+
{
|
|
501
|
+
start: {
|
|
502
|
+
line: location.start.line,
|
|
503
|
+
column: location.start.column
|
|
504
|
+
},
|
|
505
|
+
end: {
|
|
506
|
+
line: location.end.line,
|
|
507
|
+
column: location.end.column
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionAppend
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return if replay.derived_partial?
|
|
19
|
+
return unless create_change?
|
|
20
|
+
|
|
21
|
+
collection = replay.collection
|
|
22
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
23
|
+
return unless collection.primary_key
|
|
24
|
+
return unless collection.appendable? || unfilled_limit_window?(collection)
|
|
25
|
+
|
|
26
|
+
model = constantize(collection.model)
|
|
27
|
+
return unless change.fetch(:table) == model.table_name
|
|
28
|
+
|
|
29
|
+
record = model.find_by(id: change.fetch(:id))
|
|
30
|
+
return unless record && relation_appends_record?(model, collection, record)
|
|
31
|
+
|
|
32
|
+
Replay::Recipe.new(
|
|
33
|
+
kind: :render_site_append,
|
|
34
|
+
frame_id: recipe.frame_id,
|
|
35
|
+
target_kind: recipe.target_kind,
|
|
36
|
+
target_id: recipe.target_id,
|
|
37
|
+
template: recipe.template,
|
|
38
|
+
metadata: recipe.metadata,
|
|
39
|
+
runtime: "rails",
|
|
40
|
+
replay: Replay::CollectionMember.new(
|
|
41
|
+
controller_class: replay.controller_class,
|
|
42
|
+
partial: replay.partial,
|
|
43
|
+
record: Replay.active_record_value(record),
|
|
44
|
+
options: replay.options
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :recipe, :change
|
|
52
|
+
|
|
53
|
+
def create_change?
|
|
54
|
+
change[:id] && change.fetch(:type).to_s.include?("create")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def relation_appends_record?(model, collection, record)
|
|
58
|
+
primary_key = collection.primary_key
|
|
59
|
+
snapshot_ids = collection.member_ids.map(&:to_s)
|
|
60
|
+
candidate_ids = (snapshot_ids + [record.public_send(primary_key).to_s]).uniq
|
|
61
|
+
ordered_ids = model.find_by_sql(collection.sql).filter_map do |candidate|
|
|
62
|
+
candidate_id = candidate.public_send(primary_key).to_s
|
|
63
|
+
candidate_id if candidate_ids.include?(candidate_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ordered_ids.last == record.public_send(primary_key).to_s &&
|
|
67
|
+
(snapshot_ids - ordered_ids).empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unfilled_limit_window?(collection)
|
|
71
|
+
return false unless collection.limit_value
|
|
72
|
+
|
|
73
|
+
limit = Integer(collection.limit_value)
|
|
74
|
+
limit.positive? && collection.member_ids.size < limit
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def constantize(name)
|
|
80
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Invalidation
|
|
5
|
+
class CollectionMemberReplace
|
|
6
|
+
def self.build(recipe:, change:)
|
|
7
|
+
new(recipe, change).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(recipe, change)
|
|
11
|
+
@recipe = recipe
|
|
12
|
+
@change = change
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
replay = recipe.replay
|
|
17
|
+
return unless replay.is_a?(Replay::Collection)
|
|
18
|
+
return if replay.derived_partial?
|
|
19
|
+
return unless update_change?
|
|
20
|
+
|
|
21
|
+
collection = replay.collection
|
|
22
|
+
return unless collection.is_a?(Replay::ActiveRecordRelationValue)
|
|
23
|
+
return unless collection.primary_key
|
|
24
|
+
return unless collection.member_ids.map(&:to_s).include?(change.fetch(:id).to_s)
|
|
25
|
+
|
|
26
|
+
model = constantize(collection.model)
|
|
27
|
+
return unless change.fetch(:table) == model.table_name
|
|
28
|
+
|
|
29
|
+
record = model.find_by(id: change.fetch(:id))
|
|
30
|
+
return unless record && relation_keeps_member_order?(model, collection)
|
|
31
|
+
|
|
32
|
+
Replay::Recipe.new(
|
|
33
|
+
kind: :render_site_member_replace,
|
|
34
|
+
frame_id: recipe.frame_id,
|
|
35
|
+
target_kind: "dom_id",
|
|
36
|
+
target_id: dom_id(model, change.fetch(:id)),
|
|
37
|
+
template: recipe.template,
|
|
38
|
+
metadata: recipe.metadata,
|
|
39
|
+
runtime: "rails",
|
|
40
|
+
replay: Replay::CollectionMember.new(
|
|
41
|
+
controller_class: replay.controller_class,
|
|
42
|
+
partial: replay.partial,
|
|
43
|
+
record: Replay.active_record_value(record),
|
|
44
|
+
options: replay.options
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :recipe, :change
|
|
52
|
+
|
|
53
|
+
def update_change?
|
|
54
|
+
type = change.fetch(:type).to_s
|
|
55
|
+
change[:id] && !type.include?("create") && !type.include?("destroy") && !type.include?("delete")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relation_keeps_member_order?(model, collection)
|
|
59
|
+
primary_key = collection.primary_key
|
|
60
|
+
snapshot_ids = collection.member_ids.map(&:to_s)
|
|
61
|
+
ordered_ids = model.find_by_sql(collection.sql).filter_map do |candidate|
|
|
62
|
+
candidate_id = candidate.public_send(primary_key).to_s
|
|
63
|
+
candidate_id if snapshot_ids.include?(candidate_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ordered_ids == snapshot_ids
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def dom_id(model, id)
|
|
70
|
+
"#{model.model_name.param_key}_#{id}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def constantize(name)
|
|
74
|
+
name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|