upkeep-rails 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,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