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,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module Dependencies
8
+ class Base
9
+ attr_reader :source, :key, :metadata
10
+
11
+ def initialize(source:, key:, metadata: {})
12
+ @source = source
13
+ @key = key
14
+ @metadata = metadata
15
+ end
16
+
17
+ def cache_key
18
+ [source, key]
19
+ end
20
+
21
+ def matches_change?(_change)
22
+ false
23
+ end
24
+
25
+ def identity?
26
+ false
27
+ end
28
+
29
+ def identity_key
30
+ nil
31
+ end
32
+
33
+ def visibility
34
+ :public
35
+ end
36
+
37
+ def precision
38
+ :unknown
39
+ end
40
+
41
+ def narrow_frame_safe?
42
+ false
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ source: source,
48
+ key: key,
49
+ visibility: visibility,
50
+ precision: precision,
51
+ metadata: metadata
52
+ }
53
+ end
54
+ end
55
+
56
+ class ActiveRecordAttribute < Base
57
+ def initialize(table:, id:, attribute:, model: nil)
58
+ super(
59
+ source: :active_record_attribute,
60
+ key: { table: table, id: id, attribute: attribute },
61
+ metadata: { model: model }.compact
62
+ )
63
+ end
64
+
65
+ def matches_change?(change)
66
+ key.fetch(:table) == change.fetch(:table) &&
67
+ (!change[:id] || key.fetch(:id) == change[:id]) &&
68
+ change.fetch(:changed_attributes, []).include?(key.fetch(:attribute))
69
+ end
70
+
71
+ def precision
72
+ :record_attribute
73
+ end
74
+
75
+ def narrow_frame_safe?
76
+ true
77
+ end
78
+ end
79
+
80
+ class ActiveRecordCollection < Base
81
+ UNKNOWN = Object.new
82
+
83
+ def initialize(
84
+ primary_table:,
85
+ table_columns:,
86
+ coverage:,
87
+ sql:,
88
+ predicates: [],
89
+ source: :active_record_collection,
90
+ precision: :collection_predicate
91
+ )
92
+ table_columns = normalize_table_columns(table_columns)
93
+ coverage = coverage.to_sym
94
+ unless coverage == :columns
95
+ raise ArgumentError,
96
+ "unsupported Active Record predicate coverage: #{coverage}; collection dependencies require proven column coverage"
97
+ end
98
+
99
+ @precision = precision.to_sym
100
+ super(
101
+ source: source.to_sym,
102
+ key: {
103
+ table: primary_table,
104
+ predicate_digest: Digest::SHA256.hexdigest(sql)[0, 16]
105
+ },
106
+ metadata: {
107
+ primary_table: primary_table,
108
+ table_columns: table_columns,
109
+ coverage: coverage.to_s,
110
+ sql: sql,
111
+ predicates: normalize_predicates(predicates)
112
+ }
113
+ )
114
+ end
115
+
116
+ def matches_change?(change)
117
+ return false unless table_columns.key?(change.fetch(:table))
118
+
119
+ predicate_match = predicate_match(change)
120
+ return predicate_match unless predicate_match == UNKNOWN
121
+
122
+ return true if create_change?(change)
123
+ return true if delete_change?(change)
124
+
125
+ table_columns.fetch(change.fetch(:table)).intersect?(change.fetch(:changed_attributes, []))
126
+ end
127
+
128
+ def precision
129
+ @precision
130
+ end
131
+
132
+ def collection_lookup_tables
133
+ table_columns.keys.sort
134
+ end
135
+
136
+ def collection_lookup_columns
137
+ table_columns.flat_map do |table, columns|
138
+ columns.map { |column| [table, column] }
139
+ end.sort
140
+ end
141
+
142
+ private
143
+
144
+ def predicate_match(change)
145
+ predicates = predicates_for_table(change.fetch(:table))
146
+ return UNKNOWN if predicates.empty?
147
+
148
+ old_match = values_match_predicates(change.fetch(:old_values, {}), predicates)
149
+ new_match = values_match_predicates(change.fetch(:new_values, {}), predicates)
150
+ return true if old_match == true || new_match == true
151
+
152
+ if old_match == false || new_match == false
153
+ return false if predicate_columns(predicates).intersect?(change.fetch(:changed_attributes, [])) ||
154
+ create_change?(change) ||
155
+ delete_change?(change)
156
+ end
157
+
158
+ UNKNOWN
159
+ end
160
+
161
+ def create_change?(change)
162
+ change.fetch(:type).to_s.include?("create")
163
+ end
164
+
165
+ def delete_change?(change)
166
+ type = change.fetch(:type).to_s
167
+ type.include?("delete") || type.include?("destroy")
168
+ end
169
+
170
+ def values_match_predicates(values, predicates)
171
+ values = stringify_keys(values)
172
+ return UNKNOWN unless predicates.all? { |predicate| values.key?(predicate.fetch(:column)) }
173
+
174
+ predicates.all? do |predicate|
175
+ value = values.fetch(predicate.fetch(:column))
176
+ predicate.fetch(:values).include?(value)
177
+ end
178
+ end
179
+
180
+ def predicates_for_table(table)
181
+ predicates.select { |predicate| predicate.fetch(:table) == table.to_s }
182
+ end
183
+
184
+ def predicate_columns(predicates)
185
+ predicates.map { |predicate| predicate.fetch(:column) }.uniq
186
+ end
187
+
188
+ def coverage
189
+ metadata.fetch(:coverage).to_sym.tap do |value|
190
+ raise ArgumentError, "unsupported Active Record collection coverage: #{value}" unless value == :columns
191
+ end
192
+ end
193
+
194
+ def table_columns
195
+ metadata.fetch(:table_columns)
196
+ end
197
+
198
+ def predicates
199
+ metadata.fetch(:predicates)
200
+ end
201
+
202
+ def normalize_table_columns(value)
203
+ Dependencies.symbolize_keys(value).to_h do |table, columns|
204
+ [table.to_s, Array(columns).map(&:to_s).uniq.sort]
205
+ end
206
+ end
207
+
208
+ def normalize_predicates(value)
209
+ Array(value).filter_map do |predicate|
210
+ predicate = Dependencies.symbolize_keys(predicate)
211
+ values = Array(predicate[:values]).compact
212
+ next if predicate[:table].nil? || predicate[:column].nil? || values.empty?
213
+
214
+ {
215
+ table: predicate.fetch(:table).to_s,
216
+ column: predicate.fetch(:column).to_s,
217
+ operator: predicate.fetch(:operator, "eq").to_s,
218
+ values: values.uniq
219
+ }
220
+ end
221
+ end
222
+
223
+ def stringify_keys(values)
224
+ values.to_h.transform_keys(&:to_s)
225
+ end
226
+ end
227
+
228
+ class ActiveRecordQuery < ActiveRecordCollection
229
+ def initialize(primary_table:, table_columns:, coverage:, sql:, predicates: [])
230
+ super(
231
+ primary_table: primary_table,
232
+ table_columns: table_columns,
233
+ coverage: coverage,
234
+ sql: sql,
235
+ predicates: predicates,
236
+ source: :active_record_query,
237
+ precision: :query_predicate
238
+ )
239
+ end
240
+ end
241
+
242
+ class Identity < Base
243
+ def initialize(source:, key:, value:, metadata: {})
244
+ super(
245
+ source: source,
246
+ key: { key: key, value: value },
247
+ metadata: metadata
248
+ )
249
+ end
250
+
251
+ def identity?
252
+ true
253
+ end
254
+
255
+ def identity_key
256
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
257
+ end
258
+
259
+ def visibility
260
+ :identity_bound
261
+ end
262
+
263
+ def precision
264
+ :identity
265
+ end
266
+ end
267
+
268
+ class WardenUser < Identity
269
+ def initialize(scope:, user:)
270
+ super(
271
+ source: :warden_user,
272
+ key: scope.to_s,
273
+ value: Dependencies.model_identity(user),
274
+ metadata: Dependencies.model_metadata(user).merge(scope: scope.to_s)
275
+ )
276
+ end
277
+ end
278
+
279
+ class CurrentAttribute < Identity
280
+ def initialize(owner:, name:, value:)
281
+ super(
282
+ source: :current_attribute,
283
+ key: "#{owner}.#{name}",
284
+ value: Dependencies.canonical_identity(value),
285
+ metadata: { owner: owner.to_s, name: name.to_s }
286
+ )
287
+ end
288
+ end
289
+
290
+ class SessionValue < Identity
291
+ def initialize(key:, value:)
292
+ super(
293
+ source: :session,
294
+ key: key.to_s,
295
+ value: Dependencies.private_fingerprint(value),
296
+ metadata: { key: key.to_s, value_class: value.class.name }
297
+ )
298
+ end
299
+ end
300
+
301
+ class CookieValue < Identity
302
+ def initialize(key:, value:)
303
+ super(
304
+ source: :cookie,
305
+ key: key.to_s,
306
+ value: Dependencies.private_fingerprint(value),
307
+ metadata: { key: key.to_s, value_class: value.class.name }
308
+ )
309
+ end
310
+ end
311
+
312
+ class RequestValue < Identity
313
+ def initialize(key:, value:)
314
+ super(
315
+ source: :request,
316
+ key: key.to_s,
317
+ value: Dependencies.private_fingerprint(value),
318
+ metadata: { key: key.to_s, value_class: value.class.name }
319
+ )
320
+ end
321
+ end
322
+
323
+ class Unknown < Base
324
+ def initialize(source:, metadata: {})
325
+ super(
326
+ source: source,
327
+ key: Digest::SHA256.hexdigest(metadata.inspect)[0, 16],
328
+ metadata: metadata
329
+ )
330
+ end
331
+
332
+ def visibility
333
+ :private
334
+ end
335
+ end
336
+
337
+ class Restored < Base
338
+ def initialize(source:, key:, metadata:, visibility:, precision:)
339
+ super(source: source, key: key, metadata: metadata)
340
+ @visibility = visibility.to_sym
341
+ @precision = precision.to_sym
342
+ end
343
+
344
+ def identity?
345
+ visibility == :identity_bound
346
+ end
347
+
348
+ def identity_key
349
+ return unless identity?
350
+
351
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
352
+ end
353
+
354
+ attr_reader :visibility, :precision
355
+ end
356
+
357
+ module_function
358
+
359
+ def from_h(snapshot)
360
+ snapshot = symbolize_keys(snapshot)
361
+ source = snapshot.fetch(:source)
362
+ key = symbolize_keys(snapshot.fetch(:key))
363
+ metadata = symbolize_keys(snapshot.fetch(:metadata))
364
+
365
+ case source.to_sym
366
+ when :active_record_attribute
367
+ ActiveRecordAttribute.new(
368
+ table: key.fetch(:table),
369
+ id: key.fetch(:id),
370
+ attribute: key.fetch(:attribute),
371
+ model: metadata[:model]
372
+ )
373
+ when :active_record_collection, :active_record_query
374
+ dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
375
+ dependency_class.new(
376
+ primary_table: metadata.fetch(:primary_table),
377
+ table_columns: metadata.fetch(:table_columns),
378
+ coverage: metadata.fetch(:coverage),
379
+ sql: metadata.fetch(:sql),
380
+ predicates: metadata.fetch(:predicates)
381
+ )
382
+ else
383
+ if snapshot.fetch(:visibility).to_sym == :identity_bound
384
+ Identity.new(
385
+ source: source,
386
+ key: key.fetch(:key),
387
+ value: key.fetch(:value),
388
+ metadata: metadata
389
+ )
390
+ else
391
+ Restored.new(
392
+ source: source,
393
+ key: key,
394
+ metadata: metadata,
395
+ visibility: snapshot.fetch(:visibility),
396
+ precision: snapshot.fetch(:precision)
397
+ )
398
+ end
399
+ end
400
+ end
401
+
402
+ def model_identity(value)
403
+ return nil unless value
404
+
405
+ if value.respond_to?(:id) && value.class.respond_to?(:name)
406
+ { model: value.class.name, id: value.id }
407
+ end
408
+ end
409
+
410
+ def model_metadata(value)
411
+ return {} unless value
412
+
413
+ {
414
+ model: value.class.name,
415
+ table: value.class.respond_to?(:table_name) ? value.class.table_name : nil,
416
+ id: value.respond_to?(:id) ? value.id : nil
417
+ }.compact
418
+ end
419
+
420
+ def canonical_identity(value)
421
+ case value
422
+ when nil, true, false, Numeric, String, Symbol
423
+ value
424
+ else
425
+ model_identity(value) || private_fingerprint(value)
426
+ end
427
+ end
428
+
429
+ def private_fingerprint(value)
430
+ Digest::SHA256.hexdigest(JSON.generate(private_fingerprint_payload(value)))[0, 16]
431
+ end
432
+
433
+ def private_fingerprint_payload(value)
434
+ case value
435
+ when nil, true, false, Numeric, String
436
+ [value.class.name, value]
437
+ when Symbol
438
+ ["Symbol", value.to_s]
439
+ when Array
440
+ ["Array", value.map { |item| private_fingerprint_payload(item) }]
441
+ when Hash
442
+ entries = value.keys.sort_by { |key| JSON.generate(private_fingerprint_payload(key)) }.map do |key|
443
+ [private_fingerprint_payload(key), private_fingerprint_payload(value.fetch(key))]
444
+ end
445
+ ["Hash", entries]
446
+ else
447
+ identity = model_identity(value)
448
+ identity ? ["Model", identity] : ["Object", value.class.name, value.inspect]
449
+ end
450
+ end
451
+
452
+ def symbolize_keys(value)
453
+ case value
454
+ when Hash
455
+ value.each_with_object({}) do |(key, nested_value), result|
456
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
457
+ result[normalized_key] = symbolize_keys(nested_value)
458
+ end
459
+ when Array
460
+ value.map { |nested_value| symbolize_keys(nested_value) }
461
+ else
462
+ value
463
+ end
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class DeveloperReport
8
+ FALLBACK_ACTIONS = {
9
+ "helper_hidden_collection" => "Move collection rendering out of helper-only HTML and into an explicit render site.",
10
+ "manifest_runtime_mismatch" => "Inspect manifest provenance mismatches before trusting narrow updates.",
11
+ "multi_root_partial" => "Wrap the partial in one stable root element so it can carry a fragment marker.",
12
+ "no_herb_render_site" => "Extract updateable repeated markup into a partial render so Herb can plan a render site.",
13
+ "page_dependency_without_narrower_frame" => "Add a fragment or render-site boundary around the data-dependent region.",
14
+ "parse_failure" => "Fix the Herb parse error before using source-derived update addresses.",
15
+ "preloaded_plain_data" => "Keep record identity available to the view or attach summaries to their source records."
16
+ }.freeze
17
+
18
+ def initialize(manifests:, proof_report: nil)
19
+ @manifests = manifests
20
+ @proof_report = proof_report
21
+ end
22
+
23
+ def to_h
24
+ actions = template_actions + fallback_actions
25
+
26
+ {
27
+ summary: TemplateManifest.summary(manifests).merge(
28
+ page_fallback_reasons: page_fallback_reasons,
29
+ actionable_items: actions.size,
30
+ gate_passed: actions.all? { |action| action.fetch(:message) }
31
+ ),
32
+ templates: manifests.map { |manifest| template_report(manifest) },
33
+ actions: actions
34
+ }
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :manifests, :proof_report
40
+
41
+ def template_report(manifest)
42
+ {
43
+ path: manifest.path,
44
+ kind: manifest.partial? ? "partial" : "page",
45
+ parse_ok: manifest.parse.fetch(:ok),
46
+ render_sites: manifest.render_nodes.size,
47
+ fragment_root_tags: manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "fragment_root" },
48
+ helper_lowered_elements: manifest.helper_lowered_elements.size,
49
+ blockers: template_blockers(manifest)
50
+ }
51
+ end
52
+
53
+ def template_actions
54
+ manifests.flat_map do |manifest|
55
+ template_blockers(manifest).map do |blocker|
56
+ {
57
+ source: "template",
58
+ path: manifest.path,
59
+ reason: blocker,
60
+ message: template_action_message(blocker)
61
+ }
62
+ end
63
+ end
64
+ end
65
+
66
+ def template_blockers(manifest)
67
+ blockers = []
68
+ blockers << "parse_failure" unless manifest.parse.fetch(:ok)
69
+ blockers << "partial_without_single_root" if manifest.partial? && manifest.parse.fetch(:ok) && !manifest.root_shape.fetch(:single_root, false)
70
+ blockers << "page_without_render_site" if !manifest.partial? && manifest.parse.fetch(:ok) && manifest.render_nodes.empty?
71
+ blockers << "helper_lowered_html" if manifest.helper_lowered_elements.any?
72
+ blockers
73
+ end
74
+
75
+ def template_action_message(blocker)
76
+ case blocker
77
+ when "parse_failure"
78
+ FALLBACK_ACTIONS.fetch("parse_failure")
79
+ when "partial_without_single_root"
80
+ FALLBACK_ACTIONS.fetch("multi_root_partial")
81
+ when "page_without_render_site"
82
+ FALLBACK_ACTIONS.fetch("no_herb_render_site")
83
+ when "helper_lowered_html"
84
+ "Replace helper-lowered HTML with explicit template structure when it should be independently updateable."
85
+ end
86
+ end
87
+
88
+ def fallback_actions
89
+ proof_cases.flat_map do |test_case|
90
+ test_case.fetch(:selected_targets).filter_map do |target|
91
+ next unless target.fetch(:kind) == "page"
92
+
93
+ fallback_reason = target[:fallback_reason]
94
+ next unless fallback_reason
95
+
96
+ {
97
+ source: "proof",
98
+ case: test_case.fetch(:name),
99
+ target: target.fetch(:id),
100
+ reason: fallback_reason,
101
+ message: FALLBACK_ACTIONS.fetch(fallback_reason, "Add a narrower template boundary or keep the page fallback.")
102
+ }
103
+ end
104
+ end
105
+ end
106
+
107
+ def proof_cases
108
+ Array(proof_report&.fetch(:cases, []))
109
+ end
110
+
111
+ def page_fallback_reasons
112
+ proof_report&.fetch(:summary, {})&.fetch(:page_fallback_reasons, {}) || {}
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "manifest_diff"
5
+
6
+ module Upkeep
7
+ module HerbSupport
8
+ class ManifestCache
9
+ Entry = Data.define(:path, :source_digest, :source, :manifest, :last_update)
10
+
11
+ attr_reader :entries
12
+
13
+ def initialize
14
+ @entries = {}
15
+ end
16
+
17
+ def fetch(path:, source:, parse_options: ManifestDiff::PARSE_OPTIONS)
18
+ source_digest = digest(source)
19
+ entry = entries[path]
20
+
21
+ return entry.manifest if entry&.source_digest == source_digest
22
+
23
+ update = update_for(path: path, old_source: entry&.source, new_source: source, parse_options: parse_options)
24
+ manifest = update[:new_manifest] || TemplateManifest.build(path: path, source: source, parse_options: parse_options)
25
+ entries[path] = Entry.new(path, source_digest, source.dup, manifest, update_payload(update))
26
+
27
+ manifest
28
+ end
29
+
30
+ def last_update_for(path)
31
+ entries.fetch(path).last_update
32
+ end
33
+
34
+ def summary
35
+ updates = entries.values.map(&:last_update)
36
+
37
+ {
38
+ entries: entries.size,
39
+ actions: updates.map { |update| update.fetch(:action) }.tally,
40
+ topology_changes: updates.count { |update| update.fetch(:topology_changed, false) }
41
+ }
42
+ end
43
+
44
+ def clear
45
+ entries.clear
46
+ end
47
+
48
+ private
49
+
50
+ def update_for(path:, old_source:, new_source:, parse_options:)
51
+ return initial_update(path: path, source: new_source, parse_options: parse_options) unless old_source
52
+
53
+ ManifestDiff.plan(path: path, old_source: old_source, new_source: new_source, parse_options: parse_options).to_h
54
+ end
55
+
56
+ def initial_update(path:, source:, parse_options:)
57
+ manifest = TemplateManifest.build(path: path, source: source, parse_options: parse_options)
58
+
59
+ {
60
+ path: path,
61
+ action: "initial_build",
62
+ reason: "new_template",
63
+ topology_changed: true,
64
+ diff_identical: false,
65
+ operation_types: [],
66
+ operations: [],
67
+ new_manifest: manifest,
68
+ new_manifest_fingerprint: manifest.fingerprint,
69
+ stable_topology: false,
70
+ gate_passed: manifest.parse.fetch(:ok)
71
+ }
72
+ end
73
+
74
+ def update_payload(update)
75
+ update.reject { |key, _value| %i[old_manifest new_manifest old_topology_signature new_topology_signature].include?(key) }
76
+ end
77
+
78
+ def digest(source)
79
+ Digest::SHA256.hexdigest(source)
80
+ end
81
+ end
82
+ end
83
+ end