upkeep-rails 0.1.0

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Invalidation
5
+ class CollectionPrepend
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_prepends_record?(model, collection, record)
31
+
32
+ Replay::Recipe.new(
33
+ kind: :render_site_prepend,
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_prepends_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.first == 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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Invalidation
5
+ class CollectionRemove
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 unless destroy_change?
19
+
20
+ collection = replay.collection
21
+ return unless collection.is_a?(Replay::ActiveRecordRelationValue)
22
+ return unless collection.member_ids.map(&:to_s).include?(change.fetch(:id).to_s)
23
+
24
+ model = constantize(collection.model)
25
+ return unless change.fetch(:table) == model.table_name
26
+
27
+ Replay::Recipe.new(
28
+ kind: :render_site_remove,
29
+ frame_id: recipe.frame_id,
30
+ target_kind: "dom_id",
31
+ target_id: dom_id(model, change.fetch(:id)),
32
+ template: recipe.template,
33
+ metadata: recipe.metadata,
34
+ runtime: "rails",
35
+ replay: Replay::Empty.new
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :recipe, :change
42
+
43
+ def destroy_change?
44
+ type = change.fetch(:type).to_s
45
+ change[:id] && (type.include?("destroy") || type.include?("delete"))
46
+ end
47
+
48
+ def dom_id(model, id)
49
+ "#{model.model_name.param_key}_#{id}"
50
+ end
51
+
52
+ def constantize(name)
53
+ name.to_s.split("::").reduce(Object) { |namespace, constant_name| namespace.const_get(constant_name) }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Upkeep
6
+ module Invalidation
7
+ class Planner
8
+ PlannedTarget = Data.define(
9
+ :subscription_id,
10
+ :subscriber_id,
11
+ :subscriber_ids,
12
+ :target,
13
+ :frame_id,
14
+ :identity_signature,
15
+ :sharing_signature,
16
+ :recipe,
17
+ :matched_dependency_keys,
18
+ :action,
19
+ :deoptimization_reason
20
+ ) do
21
+ def represented_subscriber_count
22
+ subscriber_ids.size
23
+ end
24
+
25
+ def render
26
+ recipe.render_target(target)
27
+ end
28
+
29
+ def manifest_replay?
30
+ recipe.manifest_target_render?(target)
31
+ end
32
+ end
33
+
34
+ Plan = Data.define(:targets, :candidate_entries, :matched_entries) do
35
+ def summary
36
+ {
37
+ targets: targets.size,
38
+ represented_subscribers: targets.sum(&:represented_subscriber_count),
39
+ candidate_entries: candidate_entries.size,
40
+ matched_entries: matched_entries.size,
41
+ target_kinds: targets.map { |target| target.target.kind }.uniq.sort,
42
+ manifest_replay_targets: targets.count(&:manifest_replay?),
43
+ deoptimizations: targets.filter_map(&:deoptimization_reason).tally
44
+ }
45
+ end
46
+ end
47
+
48
+ def initialize(store:)
49
+ @store = store
50
+ end
51
+
52
+ def plan(changes)
53
+ changes = Array(changes)
54
+ payload = { change_count: changes.size }
55
+ ActiveSupport::Notifications.instrument("plan.upkeep", payload) do
56
+ @delivery_strategy_cache = {}
57
+ candidate_entries = store.reverse_index.entries_for(changes)
58
+ matched_entries = candidate_entries.select { |entry| changes.any? { |change| entry.dependency.matches_change?(change) } }
59
+ entries_by_subscription_id = matched_entries.group_by(&:subscription_id)
60
+ subscriptions_by_id = entries_by_subscription_id.keys.to_h do |subscription_id|
61
+ [subscription_id, store.fetch(subscription_id)]
62
+ end
63
+ represented_subscriber_ids = matched_entries.flat_map(&:represented_subscriber_ids).uniq
64
+ represented_subscriber_ids = subscriptions_by_id.values.map(&:subscriber_id).uniq if represented_subscriber_ids.empty?
65
+ shared_delivery = represented_subscriber_ids.size > 1
66
+
67
+ targets = entries_by_subscription_id.flat_map do |subscription_id, entries|
68
+ targets_for_subscription(
69
+ subscriptions_by_id.fetch(subscription_id),
70
+ entries,
71
+ changes,
72
+ shared_delivery: shared_delivery
73
+ )
74
+ end
75
+
76
+ plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries)
77
+ payload.merge!(payload_for(plan))
78
+ plan
79
+ end
80
+ ensure
81
+ @delivery_strategy_cache = nil
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :store
87
+
88
+ def payload_for(plan)
89
+ {
90
+ candidate_entries: plan.candidate_entries.size,
91
+ matched_entries: plan.matched_entries.size,
92
+ targets: plan.targets.size,
93
+ represented_subscribers: plan.targets.sum(&:represented_subscriber_count),
94
+ target_kinds: plan.targets.map { |target| target.target.kind }.uniq.sort,
95
+ manifest_replay_targets: plan.targets.count(&:manifest_replay?),
96
+ actions: plan.targets.map(&:action).tally,
97
+ deoptimizations: plan.targets.filter_map(&:deoptimization_reason).tally
98
+ }
99
+ end
100
+
101
+ def targets_for_subscription(subscription, entries, changes, shared_delivery:)
102
+ frames_by_id = Hash.new { |hash, key| hash[key] = { node: nil, dependency_keys: [], entries: [] } }
103
+
104
+ entries.each do |entry|
105
+ subscription.graph.nearest_frame_nodes_from(entry.owner_id).each do |frame|
106
+ bucket = frames_by_id[frame.id]
107
+ bucket[:node] ||= frame
108
+ bucket[:dependency_keys] << entry.dependency_cache_key
109
+ bucket[:entries] << entry
110
+ end
111
+ end
112
+
113
+ remove_contained_frames(subscription.graph, frames_by_id.values.map { |bucket| bucket.fetch(:node) }, changes: changes).filter_map do |frame|
114
+ bucket = frames_by_id.fetch(frame.id)
115
+ build_target(subscription, frame, bucket.fetch(:dependency_keys).uniq, bucket.fetch(:entries), changes, shared_delivery: shared_delivery)
116
+ end
117
+ end
118
+
119
+ def remove_contained_frames(graph, frames, changes:)
120
+ frames = prefer_render_sites_for_destroy(graph, frames.uniq(&:id), changes)
121
+
122
+ frames.reject do |frame|
123
+ frames.any? { |candidate| candidate.id != frame.id && graph.contained_by?(frame.id, candidate.id) }
124
+ end
125
+ end
126
+
127
+ def prefer_render_sites_for_destroy(graph, frames, changes)
128
+ return frames unless changes.any? { |change| destroy_change?(change) }
129
+
130
+ render_sites = frames.select { |frame| frame.payload.fetch(:kind) == "render_site" }
131
+ return frames if render_sites.empty?
132
+
133
+ frames.reject do |frame|
134
+ frame.payload.fetch(:kind) == "page" &&
135
+ render_sites.any? { |render_site| graph.contained_by?(render_site.id, frame.id) }
136
+ end
137
+ end
138
+
139
+ def build_target(subscription, frame, dependency_keys, entries, changes, shared_delivery:)
140
+ target = target_for_frame(frame)
141
+ return unless target
142
+
143
+ frame_id = Targeting::Extraction.frame_id_for(target)
144
+ recipe = subscription.replay_recipe(frame_id)
145
+ return unless recipe
146
+
147
+ identity_signature = subscription.identity_signature(frame_id)
148
+ sharing_signature = SharedStreams.signature_for(recipe) if shared_delivery && identity_signature == "public" && frame.payload.fetch(:kind) == "render_site"
149
+ action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature: sharing_signature)
150
+ target = delivery_target || target
151
+ subscriber_ids = represented_subscriber_ids(subscription, entries)
152
+
153
+ PlannedTarget.new(
154
+ subscription.id,
155
+ subscription.subscriber_id,
156
+ subscriber_ids,
157
+ target,
158
+ frame_id,
159
+ identity_signature,
160
+ sharing_signature,
161
+ recipe,
162
+ dependency_keys,
163
+ action,
164
+ deoptimization_reason
165
+ )
166
+ end
167
+
168
+ def represented_subscriber_ids(subscription, entries)
169
+ subscriber_ids = entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s)
170
+ return [subscription.subscriber_id] if subscriber_ids.empty?
171
+
172
+ subscriber_ids
173
+ end
174
+
175
+ def cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature:)
176
+ key = delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
177
+ return delivery_strategy(frame, recipe, entries, changes) unless key
178
+
179
+ @delivery_strategy_cache.fetch(key) do
180
+ @delivery_strategy_cache[key] = delivery_strategy(frame, recipe, entries, changes)
181
+ end
182
+ end
183
+
184
+ def delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
185
+ return unless sharing_signature
186
+
187
+ [
188
+ frame.payload.fetch(:kind),
189
+ frame.payload.fetch(:site_id),
190
+ sharing_signature,
191
+ entries.map { |entry| entry.dependency_cache_key.inspect }.uniq.sort,
192
+ changes.map { |change| change.slice(:type, :table, :id, :changed_attributes).inspect }.sort
193
+ ]
194
+ end
195
+
196
+ def target_for_frame(frame)
197
+ case frame.payload.fetch(:kind)
198
+ when "page"
199
+ Targeting::Target.new("page", frame.id, "page frame dependency matched committed change")
200
+ when "render_site"
201
+ Targeting::Target.new("render_site", frame.payload.fetch(:site_id), "render-site dependency matched committed change")
202
+ when "fragment"
203
+ Targeting::Target.new("fragment", frame.id, "record attribute read matched committed attributes")
204
+ end
205
+ end
206
+
207
+ def delivery_strategy(frame, recipe, entries, changes)
208
+ remove_recipe = remove_recipe_for(frame, recipe, entries, changes)
209
+ if remove_recipe
210
+ return [
211
+ "remove",
212
+ remove_recipe,
213
+ target_for_recipe(remove_recipe, "render-site member was destroyed"),
214
+ nil
215
+ ]
216
+ end
217
+
218
+ append_recipe = append_recipe_for(frame, recipe, entries, changes)
219
+ return ["append", append_recipe, nil, nil] if append_recipe
220
+
221
+ prepend_recipe = prepend_recipe_for(frame, recipe, entries, changes)
222
+ return ["prepend", prepend_recipe, nil, nil] if prepend_recipe
223
+
224
+ member_replace_recipe = member_replace_recipe_for(frame, recipe, entries, changes)
225
+ if member_replace_recipe
226
+ delivery_target = target_for_recipe(member_replace_recipe, "render-site member update kept collection order")
227
+ return ["replace", member_replace_recipe, delivery_target, nil]
228
+ end
229
+
230
+ ["replace", recipe, nil, deoptimization_reason(frame, entries, changes)]
231
+ end
232
+
233
+ def append_recipe_for(frame, recipe, entries, changes)
234
+ return unless frame.payload.fetch(:kind) == "render_site"
235
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
236
+
237
+ create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
238
+ return unless create_changes.one?
239
+
240
+ CollectionAppend.build(recipe: recipe, change: create_changes.first)
241
+ end
242
+
243
+ def prepend_recipe_for(frame, recipe, entries, changes)
244
+ return unless frame.payload.fetch(:kind) == "render_site"
245
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
246
+
247
+ create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
248
+ return unless create_changes.one?
249
+
250
+ CollectionPrepend.build(recipe: recipe, change: create_changes.first)
251
+ end
252
+
253
+ def member_replace_recipe_for(frame, recipe, entries, changes)
254
+ return unless frame.payload.fetch(:kind) == "render_site"
255
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
256
+
257
+ update_changes = changes.select do |change|
258
+ change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change)
259
+ end
260
+ return unless update_changes.one?
261
+
262
+ CollectionMemberReplace.build(recipe: recipe, change: update_changes.first)
263
+ end
264
+
265
+ def remove_recipe_for(frame, recipe, entries, changes)
266
+ return unless frame.payload.fetch(:kind) == "render_site"
267
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
268
+
269
+ destroy_changes = changes.select do |change|
270
+ change[:id] && destroy_change?(change)
271
+ end
272
+ return unless destroy_changes.one?
273
+
274
+ CollectionRemove.build(recipe: recipe, change: destroy_changes.first)
275
+ end
276
+
277
+ def deoptimization_reason(frame, entries, changes)
278
+ return unless frame.payload.fetch(:kind) == "render_site"
279
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
280
+
281
+ if changes.one? { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
282
+ "collection_create_position_unproven"
283
+ elsif changes.one? { |change| change[:id] && destroy_change?(change) }
284
+ "collection_remove_unproven"
285
+ elsif changes.one? { |change| change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change) }
286
+ "collection_member_replace_unproven"
287
+ else
288
+ "collection_multi_change_fallback"
289
+ end
290
+ end
291
+
292
+ def destroy_change?(change)
293
+ type = change.fetch(:type).to_s
294
+ type.include?("destroy") || type.include?("delete")
295
+ end
296
+
297
+ def target_for_recipe(recipe, reason)
298
+ Targeting::Target.new(recipe.target_kind, recipe.target_id, reason)
299
+ end
300
+
301
+ def deduplicate_targets(targets)
302
+ targets.each_with_object({}) do |target, indexed_targets|
303
+ key = deduplication_key(target)
304
+ indexed_targets[key] = merge_target(indexed_targets[key], target)
305
+ end.values
306
+ end
307
+
308
+ def deduplication_key(target)
309
+ subscriber_key = target.sharing_signature ? "shared" : target.subscriber_id
310
+
311
+ [
312
+ subscriber_key,
313
+ target.target.kind,
314
+ target.target.id,
315
+ target.identity_signature,
316
+ target.sharing_signature,
317
+ target.action,
318
+ target.deoptimization_reason
319
+ ]
320
+ end
321
+
322
+ def merge_target(existing, target)
323
+ return target unless existing
324
+
325
+ PlannedTarget.new(
326
+ existing.subscription_id,
327
+ existing.subscriber_id,
328
+ (existing.subscriber_ids + target.subscriber_ids).uniq.sort_by(&:to_s),
329
+ existing.target,
330
+ existing.frame_id,
331
+ existing.identity_signature,
332
+ existing.sharing_signature,
333
+ existing.recipe,
334
+ (existing.matched_dependency_keys + target.matched_dependency_keys).uniq,
335
+ existing.action,
336
+ existing.deoptimization_reason
337
+ )
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "invalidation/collection_append"
4
+ require_relative "invalidation/collection_member_replace"
5
+ require_relative "invalidation/collection_prepend"
6
+ require_relative "invalidation/collection_remove"
7
+ require_relative "invalidation/planner"