upkeep-rails 0.2.5-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -0
@@ -0,0 +1,548 @@
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
+ identity_local = root_identity_local(root_elements.first) if root_elements.one?
228
+
229
+ @root_shape = {
230
+ significant_children: significant_children.size,
231
+ root_elements: root_elements.size,
232
+ root_types: significant_children.map { |child| child.class.name },
233
+ single_root: significant_children.size == 1 && root_elements.size == 1,
234
+ single_root_element: root_elements.size == 1,
235
+ multi_root: root_elements.size > 1,
236
+ identity_local: identity_local
237
+ }.compact
238
+
239
+ if partial_template?
240
+ plan_fragment_root_tag(root_elements.first) if root_shape.fetch(:single_root)
241
+ elsif root_shape.fetch(:single_root_element)
242
+ # A page template's recorded frame target needs a matching DOM marker so it can be
243
+ # resolved during full-page rerender. Page templates routinely carry non-element
244
+ # siblings such as `content_for`/`yield :head` ERB statements that produce no enclosing
245
+ # DOM, so anchor the page-frame marker to the single root element rather than requiring
246
+ # the element to be the only significant child.
247
+ plan_page_root_tag(root_elements.first)
248
+ end
249
+
250
+ super
251
+ end
252
+
253
+ def visit_erb_render_node(node)
254
+ keywords = node.keywords
255
+ render_node = render_node_payload(node, keywords)
256
+
257
+ @render_nodes << render_node
258
+ @frontend_tag_plan << render_site_tag(render_node) if render_node.fetch(:render_site_container)
259
+
260
+ super
261
+ end
262
+
263
+ def visit_html_element_node(node)
264
+ if node.respond_to?(:element_source) && node.element_source && node.element_source != "HTML"
265
+ @helper_lowered_elements << {
266
+ location: location_payload(node.location),
267
+ tag_name: token_value(node.tag_name),
268
+ element_source: node.element_source
269
+ }
270
+ end
271
+
272
+ @html_stack.push(node)
273
+ begin
274
+ super
275
+ ensure
276
+ @html_stack.pop
277
+ end
278
+ end
279
+
280
+ private
281
+
282
+ attr_reader :path, :line_offsets, :html_stack
283
+
284
+ def build_line_offsets(source)
285
+ offsets = [0]
286
+ source.each_line(chomp: false).with_index do |line, index|
287
+ offsets[index + 1] = offsets[index] + line.bytesize
288
+ end
289
+ offsets
290
+ end
291
+
292
+ def offset_for(position)
293
+ line_offsets.fetch(position.line - 1) + position.column
294
+ end
295
+
296
+ def render_node_payload(node, keywords)
297
+ {
298
+ location: location_payload(node.location),
299
+ site_id: site_id("render", node.location),
300
+ expression: token_value(node.content)&.strip,
301
+ start_offset: offset_for(node.location.start),
302
+ end_offset: offset_for(node.location.end),
303
+ kind: render_kind(keywords),
304
+ partial: token_value(keywords&.partial),
305
+ template_path: token_value(keywords&.template_path),
306
+ layout: token_value(keywords&.layout),
307
+ collection: token_value(keywords&.collection),
308
+ object: token_value(keywords&.object),
309
+ as: token_value(keywords&.as_name),
310
+ locals: Array(keywords&.locals).map { |local| token_value(local.name) },
311
+ block_arguments: Array(node.block_arguments).map { |argument| token_value(argument.name) },
312
+ render_site_container: block_render?(node) ? nil : render_site_container_for(node, keywords),
313
+ render_site_source: render_site_source(keywords)
314
+ }
315
+ end
316
+
317
+ def plan_fragment_root_tag(root_element)
318
+ @frontend_tag_plan << {
319
+ kind: "fragment_root",
320
+ target: "root_element",
321
+ location: location_payload(root_element.location),
322
+ tag_name: token_value(root_element.tag_name),
323
+ element_source: element_source(root_element),
324
+ attributes: [
325
+ {
326
+ name: "data-upkeep-frame",
327
+ value: "<%= upkeep_frame_id %>"
328
+ },
329
+ {
330
+ name: "data-upkeep-template",
331
+ value: template_id
332
+ }
333
+ ],
334
+ update_role: "morph or replace this rendered fragment when runtime observations match a committed change"
335
+ }
336
+ end
337
+
338
+ def plan_page_root_tag(root_element)
339
+ @frontend_tag_plan << {
340
+ kind: "page_root",
341
+ target: "root_element",
342
+ location: location_payload(root_element.location),
343
+ tag_name: token_value(root_element.tag_name),
344
+ element_source: element_source(root_element),
345
+ attributes: [
346
+ {
347
+ name: "data-upkeep-page-frame",
348
+ value: "<%= upkeep_page_frame_id %>"
349
+ }
350
+ ],
351
+ update_role: "replace this rendered page when no narrower frame can safely cover a change"
352
+ }
353
+ end
354
+
355
+ def render_site_tag(render_node)
356
+ container = render_node.fetch(:render_site_container)
357
+
358
+ {
359
+ kind: "render_site",
360
+ target: "container_element",
361
+ location: container.fetch(:location),
362
+ tag_name: container.fetch(:tag_name),
363
+ element_source: container.fetch(:element_source),
364
+ site_id: render_node.fetch(:site_id),
365
+ attributes: [
366
+ {
367
+ name: "data-upkeep-render-site",
368
+ value: render_node.fetch(:site_id)
369
+ }
370
+ ],
371
+ render: {
372
+ kind: render_node.fetch(:kind),
373
+ partial: render_node.fetch(:partial),
374
+ collection: render_node.fetch(:collection),
375
+ object: render_node.fetch(:object),
376
+ as: render_node.fetch(:as),
377
+ source: render_node.fetch(:render_site_source)
378
+ },
379
+ update_role: render_site_update_role(render_node)
380
+ }
381
+ end
382
+
383
+ def render_site_update_role(render_node)
384
+ case render_node.fetch(:render_site_source)
385
+ when "collection_keyword"
386
+ "anchor collection membership while child partial roots carry per-record frame tags"
387
+ when "object_shorthand"
388
+ "anchor a render-object shorthand when ActionView runtime confirms it rendered a collection"
389
+ else
390
+ "record where ActionView runtime must confirm the rendered frame target"
391
+ end
392
+ end
393
+
394
+ def render_site_container_for(node, keywords)
395
+ source = render_site_source(keywords)
396
+ return unless source
397
+
398
+ container = html_stack.last
399
+ return unless container
400
+ return unless safe_collection_container?(container, node)
401
+
402
+ {
403
+ location: location_payload(container.location),
404
+ tag_name: token_value(container.tag_name),
405
+ element_source: element_source(container),
406
+ source: source
407
+ }
408
+ end
409
+
410
+ def render_site_source(keywords)
411
+ return "collection_keyword" if token_value(keywords&.collection)
412
+
413
+ # ActionView accepts `render @records` / `render records` as a
414
+ # polymorphic render. Herb can identify the stable DOM container, but
415
+ # Ruby decides at runtime whether the object is a collection, a single
416
+ # record, or a renderable component. Mark it as a candidate only; the
417
+ # runtime collection renderer must still confirm a collection before a
418
+ # render-site frame is recorded.
419
+ return "object_shorthand" if token_value(keywords&.object)
420
+
421
+ nil
422
+ end
423
+
424
+ def block_render?(node)
425
+ !!node.end_node
426
+ end
427
+
428
+ def safe_collection_container?(container, render_node)
429
+ children = meaningful_children(container)
430
+ children.one? && children.first.equal?(render_node)
431
+ end
432
+
433
+ def meaningful_children(node)
434
+ Array(node.body).reject { |child| insignificant_document_child?(child) }
435
+ end
436
+
437
+ def render_kind(keywords)
438
+ return "unknown" unless keywords
439
+
440
+ %i[
441
+ partial template_path layout file inline_template body plain html
442
+ renderable collection object
443
+ ].find { |name| token_value(keywords.public_send(name)) }&.to_s || "unknown"
444
+ end
445
+
446
+ def insignificant_document_child?(node)
447
+ return true if html_text?(node) && token_value(node.content).to_s.strip.empty?
448
+ return true if html_doctype?(node)
449
+ return true if erb_comment?(node)
450
+ return true if erb_silent?(node)
451
+
452
+ false
453
+ end
454
+
455
+ def root_identity_local(root_element)
456
+ attributes = Array(root_element&.open_tag&.children).select do |child|
457
+ child.class.name == "Herb::AST::HTMLAttributeNode"
458
+ end
459
+ id_attribute = attributes.find { |attribute| attribute_name(attribute) == "id" }
460
+ expressions = Array(id_attribute&.value&.children).filter_map do |child|
461
+ next unless child.class.name == "Herb::AST::ERBContentNode"
462
+ next unless token_value(child.tag_opening) == "<%="
463
+
464
+ token_value(child.content).to_s.strip
465
+ end
466
+ return unless expressions.one?
467
+
468
+ expressions.first.match(/\Adom_id(?:\s*\(\s*|\s+)([a-z_]\w*)/)&.captures&.first
469
+ end
470
+
471
+ def attribute_name(attribute)
472
+ children = attribute.name.respond_to?(:children) ? attribute.name.children : []
473
+ children.filter_map do |child|
474
+ token_value(child.respond_to?(:content) ? child.content : child)
475
+ end.join
476
+ end
477
+
478
+ def html_text?(node)
479
+ node.class.name == "Herb::AST::HTMLTextNode"
480
+ end
481
+
482
+ def html_element?(node)
483
+ [
484
+ "Herb::AST::HTMLElementNode",
485
+ "Herb::AST::HTMLConditionalElementNode"
486
+ ].include?(node.class.name)
487
+ end
488
+
489
+ def element_source(node)
490
+ node.element_source
491
+ end
492
+
493
+ def html_doctype?(node)
494
+ node.class.name == "Herb::AST::HTMLDoctypeNode"
495
+ end
496
+
497
+ def erb_comment?(node)
498
+ node.respond_to?(:tag_opening) && token_value(node.tag_opening).to_s.start_with?("<%#")
499
+ end
500
+
501
+ def erb_silent?(node)
502
+ node.respond_to?(:tag_opening) && token_value(node.tag_opening) == "<%"
503
+ end
504
+
505
+ def partial_template?
506
+ File.basename(path).start_with?("_")
507
+ end
508
+
509
+ def template_id
510
+ Digest::SHA256.hexdigest(path)[0, 16]
511
+ end
512
+
513
+ def site_id(kind, location)
514
+ Digest::SHA256.hexdigest([
515
+ path,
516
+ kind,
517
+ location&.start&.line,
518
+ location&.start&.column,
519
+ location&.end&.line,
520
+ location&.end&.column
521
+ ].join(":"))[0, 16]
522
+ end
523
+
524
+ def token_value(token)
525
+ return nil unless token
526
+ return token.value if token.respond_to?(:value)
527
+
528
+ token.to_s
529
+ end
530
+
531
+ def location_payload(location)
532
+ return nil unless location
533
+
534
+ {
535
+ start: {
536
+ line: location.start.line,
537
+ column: location.start.column
538
+ },
539
+ end: {
540
+ line: location.end.line,
541
+ column: location.end.column
542
+ }
543
+ }
544
+ end
545
+ end
546
+ end
547
+ end
548
+ 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