upkeep-rails 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. metadata +308 -0
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require "active_support/notifications"
6
+ require_relative "active_registry"
7
+
8
+ module Upkeep
9
+ module Subscriptions
10
+ class NotFound < KeyError; end
11
+
12
+ Subscription = Data.define(:id, :subscriber_id, :recorder, :graph, :metadata) do
13
+ def explain
14
+ dependency_nodes = graph.dependency_nodes
15
+ dependencies = dependency_nodes.map(&:payload)
16
+
17
+ {
18
+ id: id,
19
+ subscriber_id: subscriber_id,
20
+ tables: active_record_tables(dependencies),
21
+ identity: identity_dependencies(dependencies),
22
+ frame_count: graph.frame_nodes.size,
23
+ dependency_count: dependency_nodes.size,
24
+ lookup_keys: lookup_keys_for(dependencies),
25
+ metadata: explain_metadata
26
+ }
27
+ end
28
+
29
+ def identity_signature(frame_id)
30
+ recorder.identity_signature(frame_id)
31
+ end
32
+
33
+ def replay_recipe(frame_id)
34
+ graph.node(frame_id).payload[:recipe]
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ id: id,
40
+ subscriber_id: subscriber_id,
41
+ recorder: recorder.to_h,
42
+ metadata: metadata
43
+ }
44
+ end
45
+
46
+ def to_persistent_h
47
+ {
48
+ id: id,
49
+ subscriber_id: subscriber_id,
50
+ recorder: recorder.to_persistent_h,
51
+ metadata: metadata
52
+ }
53
+ end
54
+
55
+ def self.from_h(snapshot)
56
+ snapshot = Dependencies.symbolize_keys(snapshot)
57
+ recorder = Runtime::Recorder.from_h(snapshot.fetch(:recorder))
58
+
59
+ new(
60
+ snapshot.fetch(:id),
61
+ snapshot.fetch(:subscriber_id),
62
+ recorder,
63
+ recorder.graph,
64
+ snapshot.fetch(:metadata)
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def active_record_tables(dependencies)
71
+ tables = Hash.new { |hash, table| hash[table] = [] }
72
+
73
+ dependencies.each do |dependency|
74
+ case dependency.source
75
+ when :active_record_attribute
76
+ tables[dependency.key.fetch(:table).to_s] << dependency.key.fetch(:attribute).to_s
77
+ when :active_record_collection, :active_record_query
78
+ dependency.metadata.fetch(:table_columns, {}).each do |table, columns|
79
+ tables[table.to_s].concat(Array(columns).map(&:to_s))
80
+ end
81
+ end
82
+ end
83
+
84
+ tables.transform_values { |columns| columns.uniq.sort }.sort.to_h
85
+ end
86
+
87
+ def identity_dependencies(dependencies)
88
+ dependencies.select(&:identity?).map do |dependency|
89
+ {
90
+ source: dependency.source.to_s,
91
+ key: dependency.key.fetch(:key),
92
+ value: dependency.key.fetch(:value),
93
+ partitioning: Dependencies.partitioning_identity?(dependency)
94
+ }
95
+ end.sort_by(&:inspect)
96
+ end
97
+
98
+ def lookup_keys_for(dependencies)
99
+ index = ReverseIndex.new
100
+ dependencies.flat_map { |dependency| index.lookup_keys_for_dependency(dependency) }.uniq.sort_by(&:inspect)
101
+ end
102
+
103
+ def explain_metadata
104
+ keys = [
105
+ :path,
106
+ "path",
107
+ :stream_name,
108
+ "stream_name",
109
+ :shared_stream_names,
110
+ "shared_stream_names",
111
+ :identity_mode,
112
+ "identity_mode",
113
+ :identity_sources,
114
+ "identity_sources",
115
+ :identity_names,
116
+ "identity_names",
117
+ :subscription_shape_key,
118
+ "subscription_shape_key"
119
+ ]
120
+
121
+ metadata.to_h.select { |key, _value| keys.include?(key) }
122
+ end
123
+ end
124
+
125
+ class MemoryReverseIndex
126
+ LOOKUP_NOTIFICATION = "lookup_subscription_index.upkeep"
127
+
128
+ def initialize(active_registry:, pending_registry:)
129
+ @active_registry = active_registry
130
+ @pending_registry = pending_registry
131
+ end
132
+
133
+ def entries_for(changes)
134
+ if ActiveSupport::Notifications.notifier.listening?(LOOKUP_NOTIFICATION)
135
+ payload = { changes: Array(changes).size, store: "memory" }
136
+ ActiveSupport::Notifications.instrument(LOOKUP_NOTIFICATION, payload) do
137
+ entries_for_with_payload(changes, payload)
138
+ end
139
+ else
140
+ active_registry.entries_for(changes)
141
+ end
142
+ end
143
+
144
+ def summary
145
+ active_registry.summary
146
+ end
147
+
148
+ private
149
+
150
+ attr_reader :active_registry, :pending_registry
151
+
152
+ def entries_for_with_payload(changes, payload)
153
+ active_entries = active_registry.entries_for(changes)
154
+ pending_entries = pending_registry.entries_for(changes)
155
+
156
+ payload.merge!(
157
+ active_entries: active_entries.size,
158
+ active_subscriptions: active_registry.count,
159
+ pending_entries: pending_entries.size,
160
+ pending_subscriptions: pending_registry.count,
161
+ persistent_entries: 0,
162
+ persistent_direct_index_entries: 0,
163
+ persistent_shape_index_entries: 0,
164
+ persistent_direct_lookup_keys: 0,
165
+ persistent_shape_lookup_keys: 0,
166
+ persistent_shape_keys: 0,
167
+ persistent_shape_subscriptions: 0,
168
+ mode: active_entries.empty? && pending_entries.any? ? "pending_activation" : "active"
169
+ )
170
+
171
+ payload[:miss_reason] = active_entries.empty? ? miss_reason(pending_entries) : nil
172
+ payload.delete(:miss_reason) unless payload[:miss_reason]
173
+
174
+ active_entries
175
+ end
176
+
177
+ def miss_reason(pending_entries)
178
+ pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
179
+ end
180
+ end
181
+
182
+ class Store
183
+ PERSIST_NOTIFICATION = "persist_subscription_store.upkeep"
184
+
185
+ attr_reader :reverse_index
186
+
187
+ def initialize(reverse_index: ReverseIndex.new)
188
+ @active_registry = ActiveRegistry.new(reverse_index: reverse_index)
189
+ @pending_registry = ActiveRegistry.new
190
+ @pending_index_entries = {}
191
+ @reverse_index = MemoryReverseIndex.new(active_registry: active_registry, pending_registry: pending_registry)
192
+ @next_id = 0
193
+ end
194
+
195
+ def register(subscriber_id:, recorder:, metadata: {}, entries: nil)
196
+ if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
197
+ payload = memory_persist_payload(operation: :persist_subscription)
198
+ ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
199
+ register_subscription(subscriber_id: subscriber_id, recorder: recorder, metadata: metadata, entries: entries, payload: payload)
200
+ end
201
+ else
202
+ register_subscription(subscriber_id: subscriber_id, recorder: recorder, metadata: metadata, entries: entries)
203
+ end
204
+ end
205
+
206
+ def touch(id, now: Time.now)
207
+ fetch(id)
208
+ metadata = { "last_seen_at" => now.utc.iso8601 }
209
+ pending_registry.touch(id, metadata: metadata)
210
+ active_registry.touch(id, metadata: metadata)
211
+ true
212
+ end
213
+
214
+ def prune_stale!(older_than:)
215
+ stale_ids = subscriptions.filter_map do |subscription|
216
+ id = subscription.id
217
+ id if last_seen_at(subscription) && last_seen_at(subscription) < older_than
218
+ end
219
+
220
+ unregister(stale_ids)
221
+ stale_ids.size
222
+ end
223
+
224
+ def unregister(ids)
225
+ ids = Array(ids)
226
+ ids.each { |id| @pending_index_entries.delete(id) }
227
+ pending_registry.unregister(ids)
228
+ active_registry.unregister(ids)
229
+ ids.size
230
+ end
231
+
232
+ def activate(id)
233
+ if ActiveSupport::Notifications.notifier.listening?(PERSIST_NOTIFICATION)
234
+ payload = memory_persist_payload(operation: :persist_index)
235
+ ActiveSupport::Notifications.instrument(PERSIST_NOTIFICATION, payload) do
236
+ activate_subscription(id, payload: payload)
237
+ end
238
+ else
239
+ activate_subscription(id)
240
+ end
241
+ end
242
+
243
+ def drain
244
+ true
245
+ end
246
+
247
+ def shutdown
248
+ true
249
+ end
250
+
251
+ def fetch(id)
252
+ active_registry.fetch(id) || pending_registry.fetch(id) || raise(NotFound, id)
253
+ end
254
+
255
+ def explain(id)
256
+ fetch(id).explain
257
+ end
258
+
259
+ def subscriptions
260
+ active_registry.subscriptions + pending_registry.subscriptions
261
+ end
262
+
263
+ def reset
264
+ @active_registry = ActiveRegistry.new
265
+ @pending_registry = ActiveRegistry.new
266
+ @pending_index_entries = {}
267
+ @reverse_index = MemoryReverseIndex.new(active_registry: active_registry, pending_registry: pending_registry)
268
+ @next_id = 0
269
+ end
270
+
271
+ def summary
272
+ active = active_registry.summary
273
+ pending = pending_registry.summary
274
+ {
275
+ subscriptions: subscriptions.size,
276
+ pending_subscriptions: pending_registry.count,
277
+ active_subscriptions: active_registry.count,
278
+ deferred_index_subscriptions: 0,
279
+ reverse_index: active.merge(
280
+ mode: :active,
281
+ active: active,
282
+ pending: pending,
283
+ persistent: { lookup_keys: 0, entries: 0 }
284
+ )
285
+ }
286
+ end
287
+
288
+ private
289
+
290
+ attr_reader :pending_registry, :active_registry
291
+
292
+ def register_subscription(subscriber_id:, recorder:, metadata: {}, entries: nil, payload: nil)
293
+ recorder.flush_pending_dependencies if recorder.respond_to?(:flush_pending_dependencies)
294
+ subscription = Subscription.new(
295
+ next_subscription_id,
296
+ subscriber_id,
297
+ recorder,
298
+ recorder.graph,
299
+ metadata
300
+ )
301
+
302
+ if payload
303
+ entry_count = dependency_entry_count(subscription, entries)
304
+ payload[:pending_index_entries] = entry_count
305
+ payload[:subscription_rows] = 1
306
+ payload[:index_rows] = 0
307
+ payload[:direct_index_rows] = 0
308
+ payload[:shape_index_rows] = 0
309
+ end
310
+
311
+ pending_registry.register(subscription, entries: entries)
312
+ @pending_index_entries[subscription.id] = entries if entries
313
+ subscription
314
+ end
315
+
316
+ def activate_subscription(id, payload: nil)
317
+ if active_registry.fetch(id)
318
+ payload.merge!(subscription_rows: 0, index_rows: 0, direct_index_rows: 0, shape_index_rows: 0) if payload
319
+ return true
320
+ end
321
+
322
+ subscription = pending_registry.fetch(id)
323
+ unless subscription
324
+ payload.merge!(subscription_rows: 0, index_rows: 0, direct_index_rows: 0, shape_index_rows: 0) if payload
325
+ return false
326
+ end
327
+
328
+ entries = @pending_index_entries.delete(id)
329
+ if payload
330
+ entry_count = dependency_entry_count(subscription, entries)
331
+ payload[:dependency_entries] = entry_count
332
+ payload[:subscription_rows] = 0
333
+ payload[:index_rows] = entry_count
334
+ payload[:direct_index_rows] = entry_count
335
+ payload[:shape_index_rows] = 0
336
+ end
337
+
338
+ active_registry.register(subscription, entries: entries)
339
+ pending_registry.unregister(id)
340
+ true
341
+ end
342
+
343
+ def memory_persist_payload(operation:)
344
+ {
345
+ store: "memory",
346
+ jobs: 1,
347
+ subscriptions: operation == :persist_subscription ? 1 : 0,
348
+ index_jobs: operation == :persist_index ? 1 : 0,
349
+ dependency_entries: 0,
350
+ pending_index_entries: 0,
351
+ operations: { operation.to_s => 1 }
352
+ }
353
+ end
354
+
355
+ def dependency_entry_count(subscription, entries)
356
+ index = ReverseIndex.new
357
+ materialized = if entries
358
+ index.entries_for_subscription_instance(entries, subscription)
359
+ else
360
+ index.entries_for_subscription(subscription)
361
+ end
362
+ materialized.uniq { |entry| [entry.owner_id, entry.dependency_cache_key] }.size
363
+ end
364
+
365
+ def last_seen_at(subscription)
366
+ value = subscription.metadata["last_seen_at"] || subscription.metadata[:last_seen_at]
367
+ Time.parse(value.to_s) if value
368
+ end
369
+
370
+ def next_subscription_id
371
+ "subscription-#{SecureRandom.uuid}"
372
+ end
373
+ end
374
+ end
375
+ 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(%([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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ VERSION = "0.1.9"
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "upkeep"
data/lib/upkeep.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "upkeep/version"
4
+ require_relative "upkeep/dependencies"
5
+ require_relative "upkeep/dag"
6
+ require_relative "upkeep/replay"
7
+ require_relative "upkeep/active_record_query"
8
+ require_relative "upkeep/runtime"
9
+ require_relative "upkeep/targeting"
10
+ require_relative "upkeep/subscriptions"
11
+ require_relative "upkeep/shared_streams"
12
+ require_relative "upkeep/invalidation"
13
+ require_relative "upkeep/delivery"
14
+ require_relative "upkeep/rails"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/upkeep/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "upkeep-rails"
7
+ spec.version = Upkeep::VERSION
8
+ spec.authors = [ "Felipe dos Anjos" ]
9
+ spec.email = [ "felipe.cavalheiro.anjos@gmail.com" ]
10
+ spec.license = "MIT"
11
+
12
+ spec.summary = "Dependency-tracked live updates for Rails views"
13
+ spec.description = "Upkeep records the data and identity dependencies used while Rails renders a view, then updates subscribed frames when matching application data changes."
14
+ spec.homepage = "https://github.com/fc-anjos/upkeep-rails"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/v#{spec.version}"
18
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
19
+ spec.metadata["rubygems_mfa_required"] = "true"
20
+
21
+ internal_files = Dir[
22
+ "lib/upkeep/probes/**/*.rb",
23
+ "lib/upkeep/proofs/**/*.rb"
24
+ ] + %w[
25
+ lib/upkeep/domain.rb
26
+ lib/upkeep/herb/fallback_analyzer.rb
27
+ lib/upkeep/herb/performance_gate.rb
28
+ lib/upkeep/herb/runtime_alignment.rb
29
+ lib/upkeep/proof_support.rb
30
+ lib/upkeep/rendering.rb
31
+ lib/upkeep/templates.rb
32
+ ]
33
+
34
+ spec.files = (Dir[
35
+ "README.md",
36
+ "docs/**/*.md",
37
+ "LICENSE.txt",
38
+ "upkeep-rails.gemspec",
39
+ "lib/**/*.rb",
40
+ "lib/generators/**/templates/**/*"
41
+ ] - internal_files).sort
42
+ spec.require_paths = [ "lib" ]
43
+
44
+ spec.add_dependency "actionview", ">= 7.1", "< 9.0"
45
+ spec.add_dependency "actionpack", ">= 7.1", "< 9.0"
46
+ spec.add_dependency "actioncable", ">= 7.1", "< 9.0"
47
+ spec.add_dependency "activejob", ">= 7.1", "< 9.0"
48
+ spec.add_dependency "activerecord", ">= 7.1", "< 9.0"
49
+ spec.add_dependency "activesupport", ">= 7.1", "< 9.0"
50
+ spec.add_dependency "herb", ">= 0.10.1", "< 0.11"
51
+ spec.add_dependency "nokogiri", ">= 1.15", "< 2.0"
52
+ spec.add_dependency "railties", ">= 7.1", "< 9.0"
53
+ spec.add_dependency "turbo-rails", ">= 2.0", "< 3.0"
54
+ end