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
+ require "active_record"
4
+
5
+ module Upkeep
6
+ module ActiveRecordQuery
7
+ class OpaqueRelationError < StandardError
8
+ attr_reader :model_name, :table_name, :sql, :reasons
9
+
10
+ def initialize(relation, reasons:)
11
+ @model_name = relation.klass.name
12
+ @table_name = relation.klass.table_name
13
+ @sql = relation.to_sql
14
+ @reasons = reasons
15
+
16
+ super(build_message)
17
+ rescue StandardError => error
18
+ super("Upkeep cannot prove this Active Record relation's structural dependencies: #{error.message}")
19
+ end
20
+
21
+ private
22
+
23
+ def build_message
24
+ <<~MESSAGE
25
+ Upkeep cannot make this Active Record relation reactive because its query shape is opaque.
26
+
27
+ Relation:
28
+ #{model_name} (#{table_name})
29
+
30
+ SQL:
31
+ #{sql}
32
+
33
+ Why:
34
+ #{reasons.map { |reason| " - #{reason}" }.join("\n")}
35
+
36
+ What to do:
37
+ - Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.
38
+ - Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.
39
+ - Render this boundary outside Upkeep reactivity when the query cannot expose its sources.
40
+ MESSAGE
41
+ end
42
+
43
+ public
44
+
45
+ def suggestions
46
+ [
47
+ "Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.",
48
+ "Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.",
49
+ "Render this boundary outside Upkeep reactivity when the query cannot expose its sources."
50
+ ]
51
+ end
52
+ end
53
+
54
+ Result = Data.define(
55
+ :primary_table,
56
+ :table_columns,
57
+ :coverage,
58
+ :sql,
59
+ :primary_key,
60
+ :appendable,
61
+ :limit_value,
62
+ :predicates
63
+ ) do
64
+ def tables = table_columns.keys.sort
65
+
66
+ def appendable?
67
+ appendable
68
+ end
69
+ end
70
+
71
+ module_function
72
+
73
+ def analyze(relation, opaque_table_policy: :raise)
74
+ collector = Collector.new(relation, opaque_table_policy: opaque_table_policy)
75
+ collector.analyze
76
+ end
77
+
78
+ class Collector
79
+ def initialize(relation, opaque_table_policy:)
80
+ @relation = relation
81
+ @opaque_table_policy = opaque_table_policy
82
+ @primary_table = relation.klass.table_name
83
+ @primary_key = relation.klass.primary_key
84
+ @table_columns = Hash.new { |hash, table| hash[table] = [] }
85
+ @table_aliases = {}
86
+ @opaque_columns = false
87
+ @opaque_tables = false
88
+ @opaque_table_reasons = []
89
+ @opaque_column_reasons = []
90
+ @predicates = []
91
+ end
92
+
93
+ def analyze
94
+ table(@primary_table)
95
+ collect_relation_shape
96
+ raise_opaque_relation! if opaque_relation? && @opaque_table_policy == :raise
97
+
98
+ Result.new(
99
+ primary_table: @primary_table,
100
+ table_columns: normalized_table_columns,
101
+ coverage: coverage,
102
+ sql: safe_sql,
103
+ primary_key: @primary_key,
104
+ appendable: appendable_relation?,
105
+ limit_value: @relation.limit_value,
106
+ predicates: normalized_predicates
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def collect_relation_shape
113
+ ast = @relation.arel.ast
114
+
115
+ ast.cores.each do |core|
116
+ walk(core.source, source: true)
117
+ walk(core.wheres)
118
+ walk(core.groups)
119
+ walk(core.havings)
120
+ end
121
+
122
+ walk(ast.orders)
123
+ walk(ast.with) if ast.respond_to?(:with)
124
+ rescue StandardError => error
125
+ opaque_table!("relation AST could not be inspected (#{error.class}: #{error.message})")
126
+ end
127
+
128
+ def coverage
129
+ return :tables if @opaque_tables || @opaque_columns
130
+
131
+ :columns
132
+ end
133
+
134
+ def normalized_table_columns
135
+ table(@primary_table)
136
+ column(@primary_table, @primary_key) if @primary_key
137
+
138
+ @table_columns.transform_values { |columns| columns.compact.uniq.sort }.sort.to_h
139
+ end
140
+
141
+ def appendable_relation?
142
+ return false unless coverage == :columns
143
+ return false if @opaque_tables
144
+ return false if @relation.limit_value || @relation.offset_value
145
+ return false if @relation.distinct_value
146
+ return false if @relation.group_values.any?
147
+ return false if !@relation.having_clause.empty?
148
+
149
+ true
150
+ end
151
+
152
+ def walk(value, source: false)
153
+ case value
154
+ when nil, true, false, Numeric, Symbol, Class, Module
155
+ nil
156
+ when Array
157
+ value.each { |entry| walk(entry, source: source) }
158
+ when Hash
159
+ value.each_value { |entry| walk(entry, source: source) }
160
+ when Arel::Attributes::Attribute
161
+ attribute(value)
162
+ when Arel::Nodes::Equality
163
+ equality_predicate(value)
164
+ walk_arel_node(value, source: source)
165
+ when Arel::Nodes::HomogeneousIn
166
+ homogeneous_in_predicate(value)
167
+ walk_arel_node(value, source: source)
168
+ when Arel::Table
169
+ table(value.name)
170
+ when Arel::Nodes::TableAlias
171
+ table_alias(value)
172
+ when Arel::Nodes::StringJoin
173
+ opaque_table!("raw SQL join")
174
+ when Arel::Nodes::BoundSqlLiteral, Arel::Nodes::SqlLiteral
175
+ source ? opaque_table!("raw SQL source") : opaque_column!("raw SQL predicate or order expression")
176
+ when String
177
+ source ? opaque_table!("string SQL source") : opaque_column!("string SQL predicate or order expression")
178
+ else
179
+ walk_arel_node(value, source: source)
180
+ end
181
+ end
182
+
183
+ def walk_arel_node(value, source:)
184
+ return unless value.is_a?(Arel::Nodes::Node)
185
+
186
+ value.instance_variables.each do |ivar|
187
+ walk(value.instance_variable_get(ivar), source: source)
188
+ end
189
+ end
190
+
191
+ def attribute(value)
192
+ table_name = table_name_for(value.relation)
193
+ return opaque_table!("attribute references an unknown table source") unless table_name
194
+ return if value.name.to_s == "*"
195
+
196
+ column(table_name, value.name)
197
+ end
198
+
199
+ def table_name_for(relation)
200
+ if relation.is_a?(Arel::Nodes::TableAlias)
201
+ table_name_for(relation.left)
202
+ elsif relation.respond_to?(:name)
203
+ name = relation.name.to_s
204
+ @table_aliases.fetch(name, name)
205
+ elsif relation.respond_to?(:left)
206
+ table_name_for(relation.left)
207
+ end
208
+ end
209
+
210
+ def table_alias(value)
211
+ table_name = table_name_for(value.left)
212
+ return opaque_table!("table alias references an unknown table source") unless table_name
213
+
214
+ @table_aliases[value.right.to_s] = table_name
215
+ table(table_name)
216
+ end
217
+
218
+ def opaque_table!(reason)
219
+ @opaque_tables = true
220
+ @opaque_table_reasons << reason
221
+ end
222
+
223
+ def opaque_column!(reason)
224
+ @opaque_columns = true
225
+ @opaque_column_reasons << reason
226
+ end
227
+
228
+ def opaque_relation?
229
+ @opaque_tables || @opaque_columns
230
+ end
231
+
232
+ def raise_opaque_relation!
233
+ raise OpaqueRelationError.new(@relation, reasons: (@opaque_table_reasons + @opaque_column_reasons).uniq)
234
+ end
235
+
236
+ def table(name)
237
+ @table_columns[name.to_s]
238
+ end
239
+
240
+ def column(table_name, column_name)
241
+ @table_columns[table_name.to_s] << column_name.to_s
242
+ end
243
+
244
+ def equality_predicate(node)
245
+ predicate = predicate_for(node.left, "eq", [predicate_value(node.right)])
246
+ @predicates << predicate if predicate
247
+ end
248
+
249
+ def homogeneous_in_predicate(node)
250
+ predicate = predicate_for(node.attribute, "in", Array(node.values).map { |value| predicate_value(value) })
251
+ @predicates << predicate if predicate
252
+ end
253
+
254
+ def predicate_for(attribute, operator, values)
255
+ return unless attribute.is_a?(Arel::Attributes::Attribute)
256
+
257
+ table_name = table_name_for(attribute.relation)
258
+ return unless table_name
259
+
260
+ values = values.compact
261
+ return if values.empty?
262
+
263
+ {
264
+ table: table_name.to_s,
265
+ column: attribute.name.to_s,
266
+ operator: operator,
267
+ values: values.uniq
268
+ }
269
+ end
270
+
271
+ def predicate_value(value)
272
+ if value.respond_to?(:value_for_database)
273
+ value.value_for_database
274
+ elsif value.respond_to?(:value)
275
+ value.value
276
+ elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
277
+ value
278
+ end
279
+ end
280
+
281
+ def normalized_predicates
282
+ @predicates
283
+ .uniq
284
+ .sort_by { |predicate| [predicate.fetch(:table), predicate.fetch(:column), predicate.fetch(:operator), predicate.fetch(:values).inspect] }
285
+ end
286
+
287
+ def safe_sql
288
+ @relation.to_sql
289
+ rescue StandardError => error
290
+ "#{error.class}: #{error.message}"
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Capture
5
+ RequestSignature = Data.define(:controller, :action, :method, :fullpath)
6
+
7
+ RequestResult = Data.define(
8
+ :action_result,
9
+ :html,
10
+ :recorder,
11
+ :response_status,
12
+ :response_content_type,
13
+ :response_media_type,
14
+ :response_successful,
15
+ :signature,
16
+ :timings,
17
+ :counters
18
+ ) do
19
+ def successful?
20
+ !!response_successful
21
+ end
22
+
23
+ def html_response?
24
+ response_media_type == "text/html" ||
25
+ response_content_type.to_s.start_with?("text/html")
26
+ end
27
+ end
28
+
29
+ module Request
30
+ module_function
31
+
32
+ def call(controller, profile: false)
33
+ timings = {}
34
+ counters = {}
35
+ action_result, recorder = measure(timings, :action_ms) do
36
+ if profile
37
+ profile_action(timings, counters) do
38
+ Runtime::Observation.capture_request(profile: true) { yield }
39
+ end
40
+ else
41
+ Runtime::Observation.capture_request { yield }
42
+ end
43
+ end
44
+ timings.merge!(recorder.profile_timings)
45
+ counters.merge!(recorder.profile_counts)
46
+ html = measure(timings, :response_body_ms) { response_body_html(controller.response.body) }
47
+ signature = measure(timings, :signature_ms) { signature_for(controller) }
48
+ RequestResult.new(
49
+ action_result,
50
+ html,
51
+ recorder,
52
+ controller.response.status,
53
+ controller.response.content_type,
54
+ controller.response.media_type,
55
+ controller.response.successful?,
56
+ signature,
57
+ timings,
58
+ counters
59
+ )
60
+ end
61
+
62
+ def profile_action(timings, counters)
63
+ collector = ActionProfiler.new
64
+ collector.capture { yield }.tap do
65
+ timings.merge!(collector.timings)
66
+ counters.merge!(collector.counters)
67
+ end
68
+ end
69
+
70
+ def signature_for(controller)
71
+ request = controller.request
72
+ RequestSignature.new(
73
+ controller.class.name,
74
+ controller.action_name,
75
+ request.request_method,
76
+ request.fullpath
77
+ )
78
+ end
79
+
80
+ def response_body_html(body)
81
+ case body
82
+ when String
83
+ body
84
+ when Array
85
+ body.join
86
+ else
87
+ return body.body.join if body.respond_to?(:body) && body.body.respond_to?(:join)
88
+ return body.to_a.join if body.respond_to?(:to_a)
89
+
90
+ body.to_s
91
+ end
92
+ end
93
+
94
+ def measure(timings, key)
95
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+ yield
97
+ ensure
98
+ timings[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
99
+ end
100
+
101
+ class ActionProfiler
102
+ EVENT_MAP = {
103
+ "sql.active_record" => :sql,
104
+ "render_template.action_view" => :render_template,
105
+ "render_partial.action_view" => :render_partial,
106
+ "render_collection.action_view" => :render_collection
107
+ }.freeze
108
+
109
+ attr_reader :timings, :counters
110
+
111
+ def initialize
112
+ @thread = Thread.current
113
+ @timings = Hash.new(0.0)
114
+ @counters = Hash.new(0)
115
+ end
116
+
117
+ def capture
118
+ callback = lambda do |name, started, finished, unique_id, payload|
119
+ next unless Thread.current.equal?(@thread)
120
+
121
+ event = ActiveSupport::Notifications::Event.new(name, started, finished, unique_id, payload)
122
+ record(event)
123
+ end
124
+
125
+ ActiveSupport::Notifications.subscribed(callback, /\A(sql\.active_record|render_(template|partial|collection)\.action_view)\z/) do
126
+ yield
127
+ end
128
+ ensure
129
+ @timings.transform_values! { |value| value.round(3) }
130
+ end
131
+
132
+ private
133
+
134
+ def record(event)
135
+ key = EVENT_MAP[event.name]
136
+ return unless key
137
+ return if ignored_sql?(event)
138
+
139
+ @timings[:"#{key}_ms"] += event.duration
140
+ @counters[:"#{key}_count"] += 1
141
+ @timings[:view_ms] += event.duration if event.name.end_with?(".action_view")
142
+ end
143
+
144
+ def ignored_sql?(event)
145
+ event.name == "sql.active_record" && event.payload[:name] == "SCHEMA"
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../shared_streams"
5
+ require_relative "../version"
6
+
7
+ module Upkeep
8
+ module DAG
9
+ class SubscriptionShape
10
+ DIGEST_SCOPE = "upkeep-subscription-shape"
11
+ FRAME_PAYLOAD_SHAPE_IGNORED_KEYS = %i[manifest recipe].freeze
12
+
13
+ attr_reader :signature
14
+
15
+ def self.from_graph(graph, request_signature: nil)
16
+ new(signature: signature_for_terms(request_signature, graph_terms(graph)))
17
+ end
18
+
19
+ def self.from_components(graph_component, request_signature: nil)
20
+ new(signature: signature_for_terms(request_signature, terms_for_component(graph_component)))
21
+ end
22
+
23
+ def self.from_terms(graph_terms, request_signature: nil)
24
+ new(signature: signature_for_terms(request_signature, graph_terms))
25
+ end
26
+
27
+ def self.from_trace_digest(trace_digest, request_signature: nil)
28
+ new(signature: signature_for_trace_digest(request_signature, trace_digest))
29
+ end
30
+
31
+ def self.signature_for_terms(request_signature, graph_terms)
32
+ digest = Digest::SHA256.new
33
+ digest.update(DIGEST_SCOPE)
34
+ digest.update("\0")
35
+ digest.update(Upkeep::VERSION)
36
+ digest.update("\0")
37
+ digest.update(canonical_value(request_signature_component(request_signature)))
38
+ %i[frames dependencies contains].each do |group|
39
+ digest.update("\0")
40
+ digest.update(group.to_s)
41
+ Array(graph_terms[group]).each do |term|
42
+ digest.update("\0")
43
+ digest.update(term)
44
+ end
45
+ end
46
+ digest.hexdigest
47
+ end
48
+
49
+ def self.signature_for_trace_digest(request_signature, trace_digest)
50
+ digest = Digest::SHA256.new
51
+ digest.update(DIGEST_SCOPE)
52
+ digest.update("\0")
53
+ digest.update(Upkeep::VERSION)
54
+ digest.update("\0")
55
+ digest.update(canonical_value(request_signature_component(request_signature)))
56
+ digest.update("\0")
57
+ digest.update(trace_digest)
58
+ digest.hexdigest
59
+ end
60
+
61
+ def self.request_signature_component(signature)
62
+ return nil unless signature
63
+
64
+ signature.respond_to?(:to_h) ? signature.to_h : signature
65
+ end
66
+
67
+ def self.graph_component(graph)
68
+ {
69
+ frames: graph.frame_nodes.map { |node| frame_component(node.id, node.payload) }.sort_by { |component| component.fetch(:id).to_s },
70
+ dependencies: graph.dependency_nodes.map { |node| dependency_component(graph, node) }.sort_by { |component| component.fetch(:id).inspect },
71
+ contains: graph.edges
72
+ .select { |edge| edge.reason == :contains }
73
+ .map { |edge| [edge.from, edge.to] }
74
+ .sort_by(&:inspect)
75
+ }
76
+ end
77
+
78
+ def self.graph_terms(graph)
79
+ {
80
+ frames: graph.frame_nodes.map { |node| frame_term(node.id, node.payload) },
81
+ dependencies: graph.dependency_nodes.map { |node| dependency_term(node.id, node.payload, graph.dependency_owner_ids(node.id)) },
82
+ contains: graph.edges
83
+ .select { |edge| edge.reason == :contains }
84
+ .map { |edge| contains_term(edge.from, edge.to) }
85
+ }
86
+ end
87
+
88
+ def self.terms_for_component(graph_component)
89
+ {
90
+ frames: graph_component.fetch(:frames).map { |component| canonical_term(:frame, component.fetch(:id), component.fetch(:payload)) },
91
+ dependencies: graph_component.fetch(:dependencies).map do |component|
92
+ canonical_term(:dependency, component.fetch(:id), component.fetch(:dependency), component.fetch(:owners))
93
+ end,
94
+ contains: graph_component.fetch(:contains).map { |from, to| contains_term(from, to) }
95
+ }
96
+ end
97
+
98
+ def self.frame_component(id, payload)
99
+ {
100
+ id: id,
101
+ payload: frame_payload_component(payload)
102
+ }
103
+ end
104
+
105
+ def self.frame_term(id, payload)
106
+ canonical_term(:frame, id, frame_payload_component(payload))
107
+ end
108
+
109
+ def self.frame_payload_component(payload)
110
+ component = payload.reject do |key, _value|
111
+ key.respond_to?(:to_sym) && FRAME_PAYLOAD_SHAPE_IGNORED_KEYS.include?(key.to_sym)
112
+ end
113
+ component = shape_value(component)
114
+ recipe = payload[:recipe] || payload["recipe"]
115
+ kind = payload[:kind] || payload["kind"]
116
+ if recipe && kind.to_s == "render_site"
117
+ component[:shared_stream_signature] = SharedStreams.signature_for(recipe)
118
+ end
119
+ component
120
+ end
121
+
122
+ def self.dependency_component(graph, node)
123
+ {
124
+ id: node.id,
125
+ dependency: shape_value(node.payload.to_h),
126
+ owners: graph.dependency_owner_ids(node.id).sort_by(&:to_s)
127
+ }
128
+ end
129
+
130
+ def self.dependency_term(id, dependency, owners)
131
+ canonical_term(:dependency, id, dependency.to_h, owners.sort_by(&:to_s))
132
+ end
133
+
134
+ def self.contains_term(from, to)
135
+ canonical_term(:contains, from, to)
136
+ end
137
+
138
+ def self.shape_value(value)
139
+ case value
140
+ when Hash
141
+ value.keys.sort_by(&:to_s).to_h { |key| [key, shape_value(value.fetch(key))] }
142
+ when Array
143
+ value.map { |item| shape_value(item) }
144
+ else
145
+ value.respond_to?(:to_h) ? shape_value(value.to_h) : value
146
+ end
147
+ end
148
+
149
+ def self.canonical_term(*parts)
150
+ parts.map { |part| canonical_value(part) }.join("\0")
151
+ end
152
+
153
+ def self.canonical_value(value)
154
+ shape_value(value).inspect
155
+ end
156
+
157
+ def initialize(signature:)
158
+ @signature = signature
159
+ end
160
+
161
+ class Trace
162
+ def initialize(graph_version:)
163
+ @graph_version = graph_version
164
+ @seen_frame_ids = {}
165
+ @seen_dependency_keys = {}
166
+ @seen_dependency_owner_ids_by_key = Hash.new { |owners, dependency_key| owners[dependency_key] = {} }
167
+ @seen_contains_edges = {}
168
+ @digest = Digest::SHA256.new
169
+ @digest.update("subscription-shape-trace")
170
+ @invalid = false
171
+ @recorded = false
172
+ end
173
+
174
+ def synchronized_with?(graph)
175
+ !@invalid && @graph_version == graph.version
176
+ end
177
+
178
+ def invalidate!
179
+ @invalid = true
180
+ end
181
+
182
+ def record_frame(frame_id, metadata, parent_id:, graph_version:)
183
+ return if @invalid
184
+
185
+ unless @seen_frame_ids.key?(frame_id)
186
+ @seen_frame_ids[frame_id] = true
187
+ record_digest_term(:frame, SubscriptionShape.frame_term(frame_id, metadata))
188
+ end
189
+ edge_key = [parent_id, frame_id]
190
+ unless @seen_contains_edges.key?(edge_key)
191
+ @seen_contains_edges[edge_key] = true
192
+ record_digest_term(:contains, SubscriptionShape.contains_term(parent_id, frame_id))
193
+ end
194
+ @recorded = true
195
+ @graph_version = graph_version
196
+ end
197
+
198
+ def record_dependency(owner_id, dependency, graph_version:)
199
+ return if @invalid
200
+
201
+ dependency_cache_key = dependency.cache_key
202
+ unless @seen_dependency_keys.key?(dependency_cache_key)
203
+ @seen_dependency_keys[dependency_cache_key] = true
204
+ dependency_payload = SubscriptionShape.shape_value(dependency.to_h)
205
+ record_digest_term(:dependency, SubscriptionShape.canonical_term(:dependency, dependency_cache_key, dependency_payload))
206
+ end
207
+ unless @seen_dependency_owner_ids_by_key[dependency_cache_key].key?(owner_id)
208
+ @seen_dependency_owner_ids_by_key[dependency_cache_key][owner_id] = true
209
+ record_digest_term(:dependency_owner, SubscriptionShape.canonical_term(:dependency_owner, dependency_cache_key, owner_id))
210
+ end
211
+ @recorded = true
212
+ @graph_version = graph_version
213
+ end
214
+
215
+ def covers?(graph)
216
+ synchronized_with?(graph) && (recorded? || graph_shape_empty?(graph))
217
+ end
218
+
219
+ def subscription_shape(request_signature: nil)
220
+ SubscriptionShape.from_trace_digest(@digest.hexdigest, request_signature: request_signature)
221
+ end
222
+
223
+ private
224
+
225
+ def recorded?
226
+ @recorded
227
+ end
228
+
229
+ def graph_shape_empty?(graph)
230
+ graph.frame_nodes.empty? &&
231
+ graph.dependency_nodes.empty? &&
232
+ graph.edges.none? { |edge| edge.reason == :contains }
233
+ end
234
+
235
+ def record_digest_term(kind, term)
236
+ @digest.update("\0")
237
+ @digest.update(kind.to_s)
238
+ @digest.update("\0")
239
+ @digest.update(term)
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end