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,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,360 @@
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
+ :shared_stream_target,
14
+ :frame_id,
15
+ :identity_signature,
16
+ :sharing_signature,
17
+ :recipe,
18
+ :matched_dependency_keys,
19
+ :action,
20
+ :deoptimization_reason
21
+ ) do
22
+ def represented_subscriber_count
23
+ subscriber_ids.size
24
+ end
25
+
26
+ def render
27
+ recipe.render_target(target)
28
+ end
29
+
30
+ def manifest_replay?
31
+ recipe.manifest_target_render?(target)
32
+ end
33
+ end
34
+
35
+ Plan = Data.define(:targets, :candidate_entries, :matched_entries) do
36
+ def summary
37
+ {
38
+ targets: targets.size,
39
+ represented_subscribers: targets.sum(&:represented_subscriber_count),
40
+ candidate_entries: candidate_entries.size,
41
+ matched_entries: matched_entries.size,
42
+ target_kinds: targets.map { |target| target.target.kind }.uniq.sort,
43
+ manifest_replay_targets: targets.count(&:manifest_replay?),
44
+ deoptimizations: targets.filter_map(&:deoptimization_reason).tally
45
+ }
46
+ end
47
+ end
48
+
49
+ def initialize(store:)
50
+ @store = store
51
+ end
52
+
53
+ def plan(changes)
54
+ changes = Array(changes)
55
+ payload = { change_count: changes.size }
56
+ ActiveSupport::Notifications.instrument("plan.upkeep", payload) do
57
+ @delivery_strategy_cache = {}
58
+ candidate_entries = store.reverse_index.entries_for(changes)
59
+ matched_entries = candidate_entries.select { |entry| changes.any? { |change| entry.dependency.matches_change?(change) } }
60
+ entries_by_subscription_id = matched_entries.group_by(&:subscription_id)
61
+ subscriptions_by_id = entries_by_subscription_id.keys.to_h do |subscription_id|
62
+ [subscription_id, store.fetch(subscription_id)]
63
+ end
64
+ represented_subscriber_ids = matched_entries.flat_map(&:represented_subscriber_ids).uniq
65
+ represented_subscriber_ids = subscriptions_by_id.values.map(&:subscriber_id).uniq if represented_subscriber_ids.empty?
66
+ shared_delivery = represented_subscriber_ids.size > 1
67
+
68
+ targets = entries_by_subscription_id.flat_map do |subscription_id, entries|
69
+ targets_for_subscription(
70
+ subscriptions_by_id.fetch(subscription_id),
71
+ entries,
72
+ changes,
73
+ shared_delivery: shared_delivery
74
+ )
75
+ end
76
+
77
+ plan = Plan.new(deduplicate_targets(targets), candidate_entries, matched_entries)
78
+ payload.merge!(payload_for(plan))
79
+ plan
80
+ end
81
+ ensure
82
+ @delivery_strategy_cache = nil
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :store
88
+
89
+ def payload_for(plan)
90
+ {
91
+ candidate_entries: plan.candidate_entries.size,
92
+ matched_entries: plan.matched_entries.size,
93
+ targets: plan.targets.size,
94
+ represented_subscribers: plan.targets.sum(&:represented_subscriber_count),
95
+ target_kinds: plan.targets.map { |target| target.target.kind }.uniq.sort,
96
+ manifest_replay_targets: plan.targets.count(&:manifest_replay?),
97
+ actions: plan.targets.map(&:action).tally,
98
+ deoptimizations: plan.targets.filter_map(&:deoptimization_reason).tally
99
+ }
100
+ end
101
+
102
+ def targets_for_subscription(subscription, entries, changes, shared_delivery:)
103
+ frames_by_id = Hash.new { |hash, key| hash[key] = { node: nil, dependency_keys: [], entries: [] } }
104
+
105
+ entries.each do |entry|
106
+ subscription.graph.nearest_frame_nodes_from(entry.owner_id).each do |frame|
107
+ bucket = frames_by_id[frame.id]
108
+ bucket[:node] ||= frame
109
+ bucket[:dependency_keys] << entry.dependency_cache_key
110
+ bucket[:entries] << entry
111
+ end
112
+ end
113
+
114
+ remove_contained_frames(subscription.graph, frames_by_id.values.map { |bucket| bucket.fetch(:node) }, changes: changes).filter_map do |frame|
115
+ bucket = frames_by_id.fetch(frame.id)
116
+ build_target(subscription, frame, bucket.fetch(:dependency_keys).uniq, bucket.fetch(:entries), changes, shared_delivery: shared_delivery)
117
+ end
118
+ end
119
+
120
+ def remove_contained_frames(graph, frames, changes:)
121
+ frames = prefer_render_sites_for_destroy(graph, frames.uniq(&:id), changes)
122
+
123
+ frames.reject do |frame|
124
+ frames.any? { |candidate| candidate.id != frame.id && graph.contained_by?(frame.id, candidate.id) }
125
+ end
126
+ end
127
+
128
+ def prefer_render_sites_for_destroy(graph, frames, changes)
129
+ return frames unless changes.any? { |change| destroy_change?(change) }
130
+
131
+ render_sites = frames.select { |frame| frame.payload.fetch(:kind) == "render_site" }
132
+ return frames if render_sites.empty?
133
+
134
+ frames.reject do |frame|
135
+ frame.payload.fetch(:kind) == "page" &&
136
+ render_sites.any? { |render_site| graph.contained_by?(render_site.id, frame.id) }
137
+ end
138
+ end
139
+
140
+ def build_target(subscription, frame, dependency_keys, entries, changes, shared_delivery:)
141
+ target = target_for_frame(frame)
142
+ return unless target
143
+
144
+ frame_id = Targeting::Extraction.frame_id_for(target)
145
+ recipe = subscription.replay_recipe(frame_id)
146
+ return unless recipe
147
+
148
+ # The enclosing frame's target is the stream the subscription registered as shared
149
+ # (see SharedStreams.names_for_graph). Member-level deopts (remove/replace) retarget the
150
+ # operation at an individual member, so capture the enclosing target now to keep shared
151
+ # delivery on the same stream the subscribers listen on.
152
+ shared_stream_target = target
153
+ identity_signature = subscription.identity_signature(frame_id)
154
+ sharing_signature = SharedStreams.signature_for(recipe) if shared_delivery && identity_signature == "public" && frame.payload.fetch(:kind) == "render_site"
155
+ action, recipe, delivery_target, deoptimization_reason = cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature: sharing_signature)
156
+ target = delivery_target || target
157
+ subscriber_ids = represented_subscriber_ids(subscription, entries)
158
+
159
+ PlannedTarget.new(
160
+ subscription.id,
161
+ subscription.subscriber_id,
162
+ subscriber_ids,
163
+ target,
164
+ shared_stream_target,
165
+ frame_id,
166
+ identity_signature,
167
+ sharing_signature,
168
+ recipe,
169
+ dependency_keys,
170
+ action,
171
+ deoptimization_reason
172
+ )
173
+ end
174
+
175
+ def represented_subscriber_ids(subscription, entries)
176
+ subscriber_ids = entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s)
177
+ return [subscription.subscriber_id] if subscriber_ids.empty?
178
+
179
+ subscriber_ids
180
+ end
181
+
182
+ def cached_delivery_strategy(frame, recipe, entries, changes, sharing_signature:)
183
+ key = delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
184
+ return delivery_strategy(frame, recipe, entries, changes) unless key
185
+
186
+ @delivery_strategy_cache.fetch(key) do
187
+ @delivery_strategy_cache[key] = delivery_strategy(frame, recipe, entries, changes)
188
+ end
189
+ end
190
+
191
+ def delivery_strategy_cache_key(frame, recipe, entries, changes, sharing_signature)
192
+ return unless sharing_signature
193
+
194
+ [
195
+ frame.payload.fetch(:kind),
196
+ frame.payload.fetch(:site_id),
197
+ sharing_signature,
198
+ entries.map { |entry| entry.dependency_cache_key.inspect }.uniq.sort,
199
+ changes.map { |change| change.slice(:type, :table, :id, :changed_attributes).inspect }.sort
200
+ ]
201
+ end
202
+
203
+ def target_for_frame(frame)
204
+ case frame.payload.fetch(:kind)
205
+ when "page"
206
+ Targeting::Target.new("page", frame.id, "page frame dependency matched committed change")
207
+ when "render_site"
208
+ Targeting::Target.new("render_site", frame.payload.fetch(:site_id), "render-site dependency matched committed change")
209
+ when "fragment"
210
+ Targeting::Target.new("fragment", frame.id, "record attribute read matched committed attributes")
211
+ end
212
+ end
213
+
214
+ def delivery_strategy(frame, recipe, entries, changes)
215
+ remove_recipe = remove_recipe_for(frame, recipe, entries, changes)
216
+ if remove_recipe
217
+ return [
218
+ "remove",
219
+ remove_recipe,
220
+ target_for_recipe(remove_recipe, "render-site member was destroyed"),
221
+ nil
222
+ ]
223
+ end
224
+
225
+ append_recipe = append_recipe_for(frame, recipe, entries, changes)
226
+ return ["append", append_recipe, nil, nil] if append_recipe
227
+
228
+ prepend_recipe = prepend_recipe_for(frame, recipe, entries, changes)
229
+ return ["prepend", prepend_recipe, nil, nil] if prepend_recipe
230
+
231
+ member_replace_recipe = member_replace_recipe_for(frame, recipe, entries, changes)
232
+ if member_replace_recipe
233
+ delivery_target = target_for_recipe(member_replace_recipe, "render-site member update kept collection order")
234
+ return ["replace", member_replace_recipe, delivery_target, nil]
235
+ end
236
+
237
+ [fallback_action_for(frame), recipe, nil, deoptimization_reason(frame, entries, changes)]
238
+ end
239
+
240
+ def fallback_action_for(frame)
241
+ case frame.payload.fetch(:kind)
242
+ when "page"
243
+ "refresh"
244
+ when "render_site"
245
+ "update"
246
+ else
247
+ "replace"
248
+ end
249
+ end
250
+
251
+ def append_recipe_for(frame, recipe, entries, changes)
252
+ return unless frame.payload.fetch(:kind) == "render_site"
253
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
254
+
255
+ create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
256
+ return unless create_changes.one?
257
+
258
+ CollectionAppend.build(recipe: recipe, change: create_changes.first)
259
+ end
260
+
261
+ def prepend_recipe_for(frame, recipe, entries, changes)
262
+ return unless frame.payload.fetch(:kind) == "render_site"
263
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
264
+
265
+ create_changes = changes.select { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
266
+ return unless create_changes.one?
267
+
268
+ CollectionPrepend.build(recipe: recipe, change: create_changes.first)
269
+ end
270
+
271
+ def member_replace_recipe_for(frame, recipe, entries, changes)
272
+ return unless frame.payload.fetch(:kind) == "render_site"
273
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
274
+
275
+ update_changes = changes.select do |change|
276
+ change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change)
277
+ end
278
+ return unless update_changes.one?
279
+
280
+ CollectionMemberReplace.build(recipe: recipe, change: update_changes.first)
281
+ end
282
+
283
+ def remove_recipe_for(frame, recipe, entries, changes)
284
+ return unless frame.payload.fetch(:kind) == "render_site"
285
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
286
+
287
+ destroy_changes = changes.select do |change|
288
+ change[:id] && destroy_change?(change)
289
+ end
290
+ return unless destroy_changes.one?
291
+
292
+ CollectionRemove.build(recipe: recipe, change: destroy_changes.first)
293
+ end
294
+
295
+ def deoptimization_reason(frame, entries, changes)
296
+ return unless frame.payload.fetch(:kind) == "render_site"
297
+ return unless entries.any? { |entry| entry.dependency.source == :active_record_collection }
298
+
299
+ if changes.one? { |change| change[:id] && change.fetch(:type).to_s.include?("create") }
300
+ "collection_create_position_unproven"
301
+ elsif changes.one? { |change| change[:id] && destroy_change?(change) }
302
+ "collection_remove_unproven"
303
+ elsif changes.one? { |change| change[:id] && !change.fetch(:type).to_s.include?("create") && !destroy_change?(change) }
304
+ "collection_member_replace_unproven"
305
+ else
306
+ "collection_multi_change_fallback"
307
+ end
308
+ end
309
+
310
+ def destroy_change?(change)
311
+ type = change.fetch(:type).to_s
312
+ type.include?("destroy") || type.include?("delete")
313
+ end
314
+
315
+ def target_for_recipe(recipe, reason)
316
+ Targeting::Target.new(recipe.target_kind, recipe.target_id, reason)
317
+ end
318
+
319
+ def deduplicate_targets(targets)
320
+ targets.each_with_object({}) do |target, indexed_targets|
321
+ key = deduplication_key(target)
322
+ indexed_targets[key] = merge_target(indexed_targets[key], target)
323
+ end.values
324
+ end
325
+
326
+ def deduplication_key(target)
327
+ subscriber_key = target.sharing_signature ? "shared" : target.subscriber_id
328
+
329
+ [
330
+ subscriber_key,
331
+ target.target.kind,
332
+ target.target.id,
333
+ target.identity_signature,
334
+ target.sharing_signature,
335
+ target.action,
336
+ target.deoptimization_reason
337
+ ]
338
+ end
339
+
340
+ def merge_target(existing, target)
341
+ return target unless existing
342
+
343
+ PlannedTarget.new(
344
+ existing.subscription_id,
345
+ existing.subscriber_id,
346
+ (existing.subscriber_ids + target.subscriber_ids).uniq.sort_by(&:to_s),
347
+ existing.target,
348
+ existing.shared_stream_target,
349
+ existing.frame_id,
350
+ existing.identity_signature,
351
+ existing.sharing_signature,
352
+ existing.recipe,
353
+ (existing.matched_dependency_keys + target.matched_dependency_keys).uniq,
354
+ existing.action,
355
+ existing.deoptimization_reason
356
+ )
357
+ end
358
+ end
359
+ end
360
+ 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"