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,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Subscriptions
5
+ class ReverseIndex
6
+ COHORT_IDENTITY_SOURCES = %w[Current.user cookie current_attribute session warden_user].freeze
7
+
8
+ Entry = Data.define(:subscription_id, :owner_id, :dependency_cache_key, :dependency, :subscriber_ids, :cohort_key) do
9
+ def represented_subscriber_ids
10
+ Array(subscriber_ids)
11
+ end
12
+
13
+ def cohort?
14
+ !!cohort_key
15
+ end
16
+ end
17
+
18
+ def initialize
19
+ @entries_by_lookup_key = Hash.new { |hash, key| hash[key] = [] }
20
+ @entry_keys_by_lookup_key = Hash.new { |hash, key| hash[key] = {} }
21
+ @lookup_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
22
+ @cohort_entries_by_index_key = {}
23
+ @cohort_members_by_index_key = Hash.new { |hash, key| hash[key] = {} }
24
+ @cohort_index_keys_by_subscription_id = Hash.new { |hash, key| hash[key] = {} }
25
+ end
26
+
27
+ def index(subscription)
28
+ index_entries(entries_for_subscription(subscription))
29
+ end
30
+
31
+ def index_entries(entries, subscription: nil)
32
+ entries = entries_for_subscription_instance(entries, subscription) if subscription
33
+
34
+ entries.each do |entry|
35
+ lookup_keys_for_dependency(entry.dependency).each do |lookup_key|
36
+ if entry.cohort?
37
+ index_cohort_entry(lookup_key, entry)
38
+ else
39
+ index_direct_entry(lookup_key, entry)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def delete_subscription(subscription_id)
46
+ lookup_keys = @lookup_keys_by_subscription_id.delete(subscription_id)&.keys || []
47
+ lookup_keys.each do |lookup_key|
48
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
49
+ next unless entries
50
+
51
+ entries.reject! { |entry| entry.subscription_id == subscription_id }
52
+ @entry_keys_by_lookup_key[lookup_key].delete_if { |entry_key, _present| entry_key.fetch(0) == subscription_id }
53
+
54
+ next unless entries.empty?
55
+
56
+ @entries_by_lookup_key.delete(lookup_key)
57
+ @entry_keys_by_lookup_key.delete(lookup_key)
58
+ end
59
+
60
+ cohort_index_keys = @cohort_index_keys_by_subscription_id.delete(subscription_id)&.keys || []
61
+ cohort_index_keys.each { |index_key| delete_cohort_subscription(index_key, subscription_id) }
62
+ end
63
+
64
+ def entries_for(changes)
65
+ raw_entries = changes
66
+ .flat_map { |change| lookup_keys_for_change(change) }
67
+ .flat_map { |lookup_key| @entries_by_lookup_key.fetch(lookup_key, []) }
68
+ .uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
69
+
70
+ collapse_cohort_entries(raw_entries)
71
+ end
72
+
73
+ def summary
74
+ {
75
+ lookup_keys: @entries_by_lookup_key.size,
76
+ entries: @entries_by_lookup_key.values.sum(&:size)
77
+ }
78
+ end
79
+
80
+ def entries_for_subscription(subscription)
81
+ subscription.recorder.flush_pending_dependencies if subscription.recorder.respond_to?(:flush_pending_dependencies)
82
+ cohort_key = cohort_key_for(subscription)
83
+ subscription.graph.dependency_nodes.flat_map do |node|
84
+ subscription.graph.dependency_owner_ids(node.id).map do |owner_id|
85
+ Entry.new(
86
+ subscription.id,
87
+ owner_id,
88
+ node.payload.cache_key,
89
+ node.payload,
90
+ [subscription.subscriber_id],
91
+ cohort_key
92
+ )
93
+ end
94
+ end
95
+ end
96
+
97
+ def entries_for_subscription_instance(entries, subscription)
98
+ entries.map { |entry| entry_for_subscription(entry, subscription) }
99
+ end
100
+
101
+ def lookup_keys_for_dependency(dependency)
102
+ case dependency.source
103
+ when :active_record_attribute
104
+ active_record_attribute_lookup_keys(dependency.key)
105
+ when :active_record_collection, :active_record_query
106
+ active_record_collection_lookup_keys(dependency)
107
+ else
108
+ []
109
+ end
110
+ end
111
+
112
+ def lookup_keys_for_change(change)
113
+ table = change.fetch(:table)
114
+ attributes = change.fetch(:changed_attributes, [])
115
+
116
+ keys = attributes.map do |attribute|
117
+ if change[:id]
118
+ [:active_record_attribute, table, change.fetch(:id), attribute]
119
+ else
120
+ [:active_record_attribute_any_id, table, attribute]
121
+ end
122
+ end
123
+
124
+ keys.concat(attributes.map { |attribute| [:active_record_collection_column, table, attribute] })
125
+ keys.uniq
126
+ end
127
+
128
+ private
129
+
130
+ def index_direct_entry(lookup_key, entry)
131
+ entry_key = [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
132
+ entry_keys = @entry_keys_by_lookup_key[lookup_key]
133
+ return if entry_keys.key?(entry_key)
134
+
135
+ @entries_by_lookup_key[lookup_key] << entry
136
+ entry_keys[entry_key] = true
137
+ @lookup_keys_by_subscription_id[entry.subscription_id][lookup_key] = true
138
+ end
139
+
140
+ def index_cohort_entry(lookup_key, entry)
141
+ index_key = cohort_index_key(lookup_key, entry)
142
+ members = @cohort_members_by_index_key[index_key]
143
+ subscriber_ids = (members[entry.subscription_id] || []) | entry.represented_subscriber_ids
144
+ return if members[entry.subscription_id] == subscriber_ids
145
+
146
+ existing_entry = @cohort_entries_by_index_key[index_key]
147
+ members[entry.subscription_id] = subscriber_ids
148
+ replacement_entry = existing_entry ? append_cohort_members(existing_entry, subscriber_ids) : cohort_entry_from(entry, members)
149
+
150
+ if existing_entry
151
+ replace_entry(lookup_key, existing_entry, replacement_entry)
152
+ else
153
+ @entries_by_lookup_key[lookup_key] << replacement_entry
154
+ end
155
+
156
+ @cohort_entries_by_index_key[index_key] = replacement_entry
157
+ @cohort_index_keys_by_subscription_id[entry.subscription_id][index_key] = true
158
+ end
159
+
160
+ def delete_cohort_subscription(index_key, subscription_id)
161
+ lookup_key = index_key.fetch(0)
162
+ members = @cohort_members_by_index_key.fetch(index_key, nil)
163
+ return unless members&.key?(subscription_id)
164
+
165
+ existing_entry = @cohort_entries_by_index_key.fetch(index_key)
166
+ members.delete(subscription_id)
167
+
168
+ if members.empty?
169
+ remove_entry(lookup_key, existing_entry)
170
+ @cohort_members_by_index_key.delete(index_key)
171
+ @cohort_entries_by_index_key.delete(index_key)
172
+ else
173
+ replacement_entry = cohort_entry_from(existing_entry, members)
174
+ replace_entry(lookup_key, existing_entry, replacement_entry)
175
+ @cohort_entries_by_index_key[index_key] = replacement_entry
176
+ end
177
+ end
178
+
179
+ def cohort_index_key(lookup_key, entry)
180
+ [lookup_key, entry.cohort_key, entry.owner_id, entry.dependency_cache_key]
181
+ end
182
+
183
+ def cohort_entry_from(entry, members)
184
+ Entry.new(
185
+ members.keys.sort_by(&:to_s).first,
186
+ entry.owner_id,
187
+ entry.dependency_cache_key,
188
+ entry.dependency,
189
+ members.values.flatten.uniq.sort_by(&:to_s),
190
+ entry.cohort_key
191
+ )
192
+ end
193
+
194
+ def append_cohort_members(entry, subscriber_ids)
195
+ Entry.new(
196
+ entry.subscription_id,
197
+ entry.owner_id,
198
+ entry.dependency_cache_key,
199
+ entry.dependency,
200
+ entry.represented_subscriber_ids | subscriber_ids,
201
+ entry.cohort_key
202
+ )
203
+ end
204
+
205
+ def replace_entry(lookup_key, existing_entry, replacement_entry)
206
+ entries = @entries_by_lookup_key.fetch(lookup_key)
207
+ index = entries.index(existing_entry)
208
+ entries[index] = replacement_entry if index
209
+ end
210
+
211
+ def remove_entry(lookup_key, entry)
212
+ entries = @entries_by_lookup_key.fetch(lookup_key, nil)
213
+ return unless entries
214
+
215
+ entries.delete(entry)
216
+
217
+ return unless entries.empty?
218
+
219
+ @entries_by_lookup_key.delete(lookup_key)
220
+ @entry_keys_by_lookup_key.delete(lookup_key)
221
+ end
222
+
223
+ def entry_for_subscription(entry, subscription)
224
+ Entry.new(
225
+ entry.subscription_id || subscription.id,
226
+ entry.owner_id,
227
+ entry.dependency_cache_key,
228
+ entry.dependency,
229
+ entry.subscriber_ids || [subscription.subscriber_id],
230
+ entry.cohort_key || cohort_key_for(subscription)
231
+ )
232
+ end
233
+
234
+ def collapse_cohort_entries(entries)
235
+ direct_entries = []
236
+ cohort_entries = Hash.new { |hash, key| hash[key] = [] }
237
+
238
+ entries.each do |entry|
239
+ if entry.cohort?
240
+ cohort_entries[[entry.cohort_key, entry.owner_id, entry.dependency_cache_key]] << entry
241
+ else
242
+ direct_entries << entry
243
+ end
244
+ end
245
+
246
+ direct_entries + cohort_entries.values.map { |group| collapse_cohort_entry_group(group) }
247
+ end
248
+
249
+ def collapse_cohort_entry_group(entries)
250
+ representative = entries.first
251
+ Entry.new(
252
+ representative.subscription_id,
253
+ representative.owner_id,
254
+ representative.dependency_cache_key,
255
+ representative.dependency,
256
+ entries.flat_map(&:represented_subscriber_ids).uniq.sort_by(&:to_s),
257
+ representative.cohort_key
258
+ )
259
+ end
260
+
261
+ def cohort_key_for(subscription)
262
+ return unless identity_free_subscription?(subscription)
263
+
264
+ metadata_value(subscription, :subscription_shape_key)
265
+ end
266
+
267
+ def identity_free_subscription?(subscription)
268
+ return false if subscription.graph.dependency_nodes.any? { |node| cohort_identity_dependency?(node.payload) }
269
+
270
+ true
271
+ end
272
+
273
+ def metadata_value(subscription, key)
274
+ subscription.metadata[key] || subscription.metadata[key.to_s]
275
+ end
276
+
277
+ def cohort_identity_dependency?(dependency)
278
+ dependency.identity? && COHORT_IDENTITY_SOURCES.include?(dependency.source.to_s)
279
+ end
280
+
281
+ def active_record_collection_lookup_keys(dependency)
282
+ dependency.collection_lookup_columns.map { |table, column| [:active_record_collection_column, table, column] }
283
+ end
284
+
285
+ def active_record_attribute_lookup_keys(key)
286
+ if key.fetch(:id)
287
+ [[:active_record_attribute, key.fetch(:table), key.fetch(:id), key.fetch(:attribute)]]
288
+ else
289
+ [[:active_record_attribute_any_id, key.fetch(:table), key.fetch(:attribute)]]
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require_relative "../shared_streams"
5
+ require_relative "reverse_index"
6
+
7
+ module Upkeep
8
+ module Subscriptions
9
+ class ShapeCache
10
+ NOTIFICATION = "subscription_shape.upkeep"
11
+
12
+ Shape = Data.define(:key, :entries, :shared_stream_names)
13
+ Result = Data.define(:key, :entries, :shared_stream_names, :cache_state, :cacheable, :reason, :timings)
14
+ TemplateSubscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata)
15
+
16
+ def initialize(index_builder: ReverseIndex.new)
17
+ @index_builder = index_builder
18
+ @shapes = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def resolve(recorder:, decision:, signature: nil)
23
+ if ActiveSupport::Notifications.notifier.listening?(NOTIFICATION)
24
+ payload = {}
25
+ ActiveSupport::Notifications.instrument(NOTIFICATION, payload) do
26
+ result = resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
27
+ payload.merge!(
28
+ key: result.key,
29
+ cache_state: result.cache_state,
30
+ cacheable: result.cacheable,
31
+ reason: result.reason,
32
+ entries: result.entries.size,
33
+ shared_stream_names: result.shared_stream_names.size
34
+ )
35
+ payload.merge!(result.timings)
36
+ result
37
+ end
38
+ else
39
+ resolve_without_instrumentation(recorder: recorder, decision: decision, signature: signature)
40
+ end
41
+ end
42
+
43
+ def reset
44
+ @mutex.synchronize { @shapes = {} }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :index_builder
50
+
51
+ def resolve_without_instrumentation(recorder:, decision:, signature:)
52
+ timings = {}
53
+ unless cacheable?(recorder, decision)
54
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: nil, timings: timings) }
55
+ return Result.new(nil, shape.entries, shape.shared_stream_names, "uncached", false, cache_bypass_reason(recorder, decision), timings)
56
+ end
57
+
58
+ key = measure(timings, :key_ms) { shape_key_for(recorder, signature: signature) }
59
+ @mutex.synchronize do
60
+ if (shape = @shapes[key])
61
+ Result.new(key, shape.entries, shape.shared_stream_names, "hit", true, nil, timings)
62
+ else
63
+ shape = measure(timings, :compile_ms) { compile_shape(recorder, key: key, timings: timings) }
64
+ @shapes[key] = shape
65
+ Result.new(key, shape.entries, shape.shared_stream_names, "miss", true, nil, timings)
66
+ end
67
+ end
68
+ end
69
+
70
+ def cacheable?(recorder, decision)
71
+ decision&.anonymous && recorder.reactive?
72
+ end
73
+
74
+ def cache_bypass_reason(recorder, decision)
75
+ return "identified" unless decision&.anonymous
76
+ return "refused_boundary" unless recorder.reactive?
77
+
78
+ "uncacheable"
79
+ end
80
+
81
+ def compile_shape(recorder, key:, timings:)
82
+ subscription = TemplateSubscription.new(nil, nil, recorder, recorder.graph, { subscription_shape_key: key }.compact)
83
+ entries = measure(timings, :index_template_ms) do
84
+ index_builder.entries_for_subscription(subscription)
85
+ end
86
+ .map { |entry| template_entry(entry) }
87
+ .uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }
88
+ .freeze
89
+ shared_stream_names = measure(timings, :shared_stream_names_ms) { SharedStreams.names_for_recorder(recorder) }.freeze
90
+ Shape.new(key, entries, shared_stream_names)
91
+ end
92
+
93
+ def template_entry(entry)
94
+ ReverseIndex::Entry.new(
95
+ nil,
96
+ entry.owner_id,
97
+ entry.dependency_cache_key,
98
+ entry.dependency,
99
+ nil,
100
+ entry.cohort_key
101
+ )
102
+ end
103
+
104
+ def shape_key_for(recorder, signature:)
105
+ recorder.subscription_shape(request_signature: signature).signature
106
+ end
107
+
108
+ def measure(timings, key)
109
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ yield
111
+ ensure
112
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Upkeep
7
+ module Subscriptions
8
+ Subscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata) do
9
+ def identity_signature(frame_id)
10
+ recorder.identity_signature(frame_id)
11
+ end
12
+
13
+ def replay_recipe(frame_id)
14
+ graph.node(frame_id).payload[:recipe]
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ id: id,
20
+ subscriber_id: subscriber_id,
21
+ recorder: recorder.to_h,
22
+ metadata: metadata
23
+ }
24
+ end
25
+
26
+ def to_persistent_h
27
+ {
28
+ id: id,
29
+ subscriber_id: subscriber_id,
30
+ recorder: recorder.to_persistent_h,
31
+ metadata: metadata
32
+ }
33
+ end
34
+
35
+ def self.from_h(snapshot)
36
+ snapshot = Dependencies.symbolize_keys(snapshot)
37
+ recorder = Runtime::Recorder.from_h(snapshot.fetch(:recorder))
38
+
39
+ new(
40
+ snapshot.fetch(:id),
41
+ snapshot.fetch(:subscriber_id),
42
+ recorder,
43
+ recorder.graph,
44
+ snapshot.fetch(:metadata)
45
+ )
46
+ end
47
+ end
48
+
49
+ class Store
50
+ attr_reader :reverse_index
51
+
52
+ def initialize(reverse_index: ReverseIndex.new)
53
+ @reverse_index = reverse_index
54
+ @subscriptions = {}
55
+ @next_id = 0
56
+ end
57
+
58
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
59
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
60
+ subscription = Subscription.new(
61
+ next_subscription_id,
62
+ subscriber_id,
63
+ recorder,
64
+ recorder.graph,
65
+ metadata
66
+ )
67
+
68
+ @subscriptions[subscription.id] = subscription
69
+ touch(subscription.id)
70
+ if entries
71
+ reverse_index.index_entries(entries, subscription: subscription)
72
+ else
73
+ reverse_index.index(subscription)
74
+ end
75
+ subscription
76
+ end
77
+
78
+ def touch(id, now: Time.now)
79
+ subscription = @subscriptions.fetch(id)
80
+ @subscriptions[id] = Subscription.new(
81
+ subscription.id,
82
+ subscription.subscriber_id,
83
+ subscription.recorder,
84
+ subscription.graph,
85
+ subscription.metadata.merge("last_seen_at" => now.utc.iso8601)
86
+ )
87
+ end
88
+
89
+ def prune_stale!(older_than:)
90
+ stale_ids = @subscriptions.filter_map do |id, subscription|
91
+ id if last_seen_at(subscription) && last_seen_at(subscription) < older_than
92
+ end
93
+
94
+ unregister(stale_ids)
95
+ stale_ids.size
96
+ end
97
+
98
+ def unregister(ids)
99
+ ids = Array(ids)
100
+ ids.each do |id|
101
+ next unless @subscriptions.delete(id)
102
+
103
+ reverse_index.delete_subscription(id)
104
+ end
105
+ ids.size
106
+ end
107
+
108
+ def activate(id)
109
+ @subscriptions.fetch(id)
110
+ true
111
+ end
112
+
113
+ def drain
114
+ true
115
+ end
116
+
117
+ def shutdown
118
+ true
119
+ end
120
+
121
+ def fetch(id)
122
+ @subscriptions.fetch(id)
123
+ end
124
+
125
+ def subscriptions
126
+ @subscriptions.values
127
+ end
128
+
129
+ def reset
130
+ @subscriptions = {}
131
+ @reverse_index = ReverseIndex.new
132
+ @next_id = 0
133
+ end
134
+
135
+ def summary
136
+ {
137
+ subscriptions: subscriptions.size,
138
+ reverse_index: reverse_index.summary
139
+ }
140
+ end
141
+
142
+ private
143
+
144
+ def next_subscription_id
145
+ "subscription-#{SecureRandom.uuid}"
146
+ end
147
+
148
+ def last_seen_at(subscription)
149
+ value = subscription.metadata["last_seen_at"] || subscription.metadata[:last_seen_at]
150
+ Time.parse(value.to_s) if value
151
+ end
152
+
153
+ def rebuild_reverse_index!
154
+ @reverse_index = ReverseIndex.new
155
+ subscriptions.each { |subscription| reverse_index.index(subscription) }
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subscriptions/reverse_index"
4
+ require_relative "subscriptions/shape"
5
+ require_relative "subscriptions/registrar"
6
+ require_relative "subscriptions/store"
7
+ require_relative "subscriptions/active_record_store"
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "nokogiri"
5
+
6
+ module Upkeep
7
+ module Targeting
8
+ Target = Data.define(:kind, :id, :reason)
9
+ Patch = Data.define(:target, :html)
10
+
11
+ class Selector
12
+ def select(recorder, changes)
13
+ graph = recorder.graph
14
+
15
+ frame_nodes =
16
+ graph.dependency_node_ids_matching(changes)
17
+ .flat_map { |dependency_id| graph.dependency_owner_ids(dependency_id) }
18
+ .flat_map { |owner_id| graph.nearest_frame_nodes_from(owner_id) }
19
+
20
+ uniq_targets(remove_contained_frames(graph, frame_nodes).filter_map { |frame| target_for_frame(frame) })
21
+ end
22
+
23
+ private
24
+
25
+ def remove_contained_frames(graph, frames)
26
+ frames.uniq(&:id).reject do |frame|
27
+ frames.any? { |candidate| candidate.id != frame.id && graph.contained_by?(frame.id, candidate.id) }
28
+ end
29
+ end
30
+
31
+ def target_for_frame(frame)
32
+ case frame.payload.fetch(:kind)
33
+ when "page"
34
+ Target.new("page", frame.id, "page frame dependency matched committed change")
35
+ when "render_site"
36
+ Target.new("render_site", frame.payload.fetch(:site_id), "render-site dependency matched committed change")
37
+ when "fragment"
38
+ Target.new("fragment", frame.id, "record attribute read matched committed attributes")
39
+ end
40
+ end
41
+
42
+ def uniq_targets(targets)
43
+ targets.uniq { |target| [target.kind, target.id] }
44
+ end
45
+ end
46
+
47
+ class Patcher
48
+ def initialize(html)
49
+ @fragment = Extraction.parse_html(html)
50
+ end
51
+
52
+ def apply(patches)
53
+ patches.each { |patch| apply_patch(patch) }
54
+ fragment.to_html
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :fragment
60
+
61
+ def apply_patch(patch)
62
+ node = node_for(patch.target)
63
+ raise "target not found in current DOM: #{patch.target.inspect}" unless node
64
+
65
+ replacement = replacement_for(patch)
66
+ node.replace(replacement)
67
+ end
68
+
69
+ def node_for(target)
70
+ Extraction.node_for(fragment, target)
71
+ end
72
+
73
+ def replacement_for(patch)
74
+ parsed = Extraction.parse_html(patch.html)
75
+ Extraction.node_for(parsed, patch.target) ||
76
+ parsed.children.find { |child| child.element? } ||
77
+ parsed.at_css("body > *")
78
+ end
79
+ end
80
+
81
+ module Extraction
82
+ module_function
83
+
84
+ def patches_from_full_rerender(full_html, targets)
85
+ targets.map { |target| Patch.new(target, extract_target_html(full_html, target)) }
86
+ end
87
+
88
+ def extract_target_html(html, target)
89
+ fragment = parse_html(html)
90
+ node = node_for(fragment, target)
91
+
92
+ raise "target not found in full rerender: #{target.inspect}" unless node
93
+
94
+ node.to_html
95
+ end
96
+
97
+ def node_for(fragment, target)
98
+ case target.kind
99
+ when "page"
100
+ fragment.at_css(%([data-upkeep-page-frame="#{css_escape(target.id)}"]))
101
+ when "fragment"
102
+ fragment.at_css(%([data-upkeep-frame="#{css_escape(target.id)}"]))
103
+ when "render_site"
104
+ fragment.at_css(%(upkeep-render-site[data-upkeep-render-site="#{css_escape(target.id)}"]))
105
+ end
106
+ end
107
+
108
+ def normalize_html(html)
109
+ parse_html(html).to_html
110
+ end
111
+
112
+ def digest_html(html)
113
+ Digest::SHA256.hexdigest(normalize_html(html))
114
+ end
115
+
116
+ def frame_id_for(target)
117
+ target.kind == "render_site" ? "site:#{target.id}" : target.id
118
+ end
119
+
120
+ def css_escape(value)
121
+ value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
122
+ end
123
+
124
+ def parse_html(html)
125
+ source = html.to_s
126
+
127
+ if source.match?(/\A\s*(?:<!doctype\b[^>]*>\s*)?<html[\s>]/i)
128
+ Nokogiri::HTML5.parse(source)
129
+ else
130
+ Nokogiri::HTML5.fragment(source)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end