upkeep-rails 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,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
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class ManifestDiff
8
+ PARSE_OPTIONS = TemplateManifest::DEFAULT_PARSE_OPTIONS.merge(
9
+ action_view_helpers: false,
10
+ transform_conditionals: false
11
+ ).freeze
12
+
13
+ CONTENT_ONLY_OPERATION_TYPES = %i[
14
+ attribute_added
15
+ attribute_removed
16
+ attribute_value_changed
17
+ text_changed
18
+ ].freeze
19
+
20
+ Plan = Data.define(
21
+ :path,
22
+ :action,
23
+ :reason,
24
+ :topology_changed,
25
+ :diff_identical,
26
+ :operation_types,
27
+ :operations,
28
+ :old_manifest,
29
+ :new_manifest,
30
+ :old_topology_signature,
31
+ :new_topology_signature,
32
+ :error
33
+ ) do
34
+ def gate_passed?
35
+ error.nil?
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ path: path,
41
+ action: action,
42
+ reason: reason,
43
+ topology_changed: topology_changed,
44
+ diff_identical: diff_identical,
45
+ operation_types: operation_types,
46
+ operations: operations,
47
+ old_manifest_fingerprint: old_manifest&.fingerprint,
48
+ new_manifest_fingerprint: new_manifest&.fingerprint,
49
+ stable_topology: old_topology_signature == new_topology_signature,
50
+ gate_passed: gate_passed?,
51
+ error: error
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ def self.plan(path:, old_source:, new_source:, parse_options: PARSE_OPTIONS)
57
+ new(path: path, old_source: old_source, new_source: new_source, parse_options: parse_options).plan
58
+ end
59
+
60
+ def initialize(path:, old_source:, new_source:, parse_options: PARSE_OPTIONS)
61
+ @path = path
62
+ @old_source = old_source
63
+ @new_source = new_source
64
+ @parse_options = parse_options
65
+ end
66
+
67
+ def plan
68
+ old_manifest = build_manifest(old_source)
69
+ new_manifest = build_manifest(new_source)
70
+ diff_result = ::Herb.diff(old_source, new_source)
71
+ old_signature = topology_signature(old_manifest)
72
+ new_signature = topology_signature(new_manifest)
73
+ action, reason, topology_changed = classify(
74
+ diff_result: diff_result,
75
+ old_manifest: old_manifest,
76
+ new_manifest: new_manifest,
77
+ old_signature: old_signature,
78
+ new_signature: new_signature
79
+ )
80
+
81
+ Plan.new(
82
+ path: path,
83
+ action: action,
84
+ reason: reason,
85
+ topology_changed: topology_changed,
86
+ diff_identical: diff_result.identical?,
87
+ operation_types: diff_result.map { |operation| operation.type.to_s },
88
+ operations: diff_result.map { |operation| operation_payload(operation) },
89
+ old_manifest: old_manifest,
90
+ new_manifest: new_manifest,
91
+ old_topology_signature: old_signature,
92
+ new_topology_signature: new_signature,
93
+ error: nil
94
+ )
95
+ rescue StandardError => error
96
+ Plan.new(
97
+ path: path,
98
+ action: "full_rebuild",
99
+ reason: "herb_diff_failed",
100
+ topology_changed: true,
101
+ diff_identical: false,
102
+ operation_types: [],
103
+ operations: [],
104
+ old_manifest: nil,
105
+ new_manifest: nil,
106
+ old_topology_signature: nil,
107
+ new_topology_signature: nil,
108
+ error: { class: error.class.name, message: error.message }
109
+ )
110
+ end
111
+
112
+ private
113
+
114
+ attr_reader :path, :old_source, :new_source, :parse_options
115
+
116
+ def build_manifest(source)
117
+ TemplateManifest.build(path: path, source: source, parse_options: parse_options)
118
+ end
119
+
120
+ def classify(diff_result:, old_manifest:, new_manifest:, old_signature:, new_signature:)
121
+ return ["full_rebuild", "manifest_parse_failure", true] unless old_manifest.parse.fetch(:ok) && new_manifest.parse.fetch(:ok)
122
+ return ["noop", "identical_source", false] if diff_result.identical?
123
+
124
+ operation_types = diff_result.map(&:type)
125
+ stable_topology = old_signature == new_signature
126
+
127
+ if content_only?(operation_types) && stable_topology
128
+ ["refresh_manifest", "content_only_stable_topology", false]
129
+ elsif stable_topology
130
+ ["rebuild_manifest", "dynamic_template_operation", true]
131
+ else
132
+ ["rebuild_manifest", "manifest_topology_changed", true]
133
+ end
134
+ end
135
+
136
+ def content_only?(operation_types)
137
+ (operation_types - CONTENT_ONLY_OPERATION_TYPES).empty?
138
+ end
139
+
140
+ def topology_signature(manifest)
141
+ return { parse_ok: false } unless manifest.parse.fetch(:ok)
142
+
143
+ {
144
+ root_shape: manifest.root_shape.slice(:significant_children, :root_elements, :root_types, :single_root, :multi_root),
145
+ fragment_roots: fragment_root_signature(manifest),
146
+ render_sites: render_site_signature(manifest)
147
+ }
148
+ end
149
+
150
+ def fragment_root_signature(manifest)
151
+ manifest.frontend_tag_plan
152
+ .select { |tag| tag.fetch(:kind) == "fragment_root" }
153
+ .map { |tag| { tag_name: tag.fetch(:tag_name), attributes: tag.fetch(:attributes).map { |attribute| attribute.fetch(:name) } } }
154
+ end
155
+
156
+ def render_site_signature(manifest)
157
+ manifest.render_nodes.map do |render_node|
158
+ {
159
+ kind: render_node.fetch(:kind),
160
+ partial: render_node.fetch(:partial),
161
+ template_path: render_node.fetch(:template_path),
162
+ collection: render_node.fetch(:collection),
163
+ object: render_node.fetch(:object),
164
+ as: render_node.fetch(:as),
165
+ locals: render_node.fetch(:locals),
166
+ block_arguments: render_node.fetch(:block_arguments)
167
+ }
168
+ end
169
+ end
170
+
171
+ def operation_payload(operation)
172
+ {
173
+ type: operation.type.to_s,
174
+ path: operation.path,
175
+ old_node: operation.old_node&.class&.name,
176
+ new_node: operation.new_node&.class&.name,
177
+ old_index: operation.old_index,
178
+ new_index: operation.new_index
179
+ }.compact
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class SourceInstrumenter
8
+ def initialize(manifest:)
9
+ @manifest = manifest
10
+ end
11
+
12
+ def instrument(source)
13
+ return source unless manifest.parse.fetch(:ok) || manifest.recovered?
14
+
15
+ apply_replacements(source, replacements_for(source))
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :manifest
21
+
22
+ def replacements_for(source)
23
+ render_site_replacements + marker_replacements(source)
24
+ end
25
+
26
+ def render_site_replacements
27
+ return [] unless manifest.parse.fetch(:ok)
28
+
29
+ manifest.render_nodes.select { |render_node| render_node.fetch(:render_site_container) }.map do |render_node|
30
+ [
31
+ render_node.fetch(:start_offset),
32
+ render_node.fetch(:end_offset),
33
+ %(<%= upkeep_frame("#{render_node.fetch(:site_id)}", manifest_path: "#{manifest.path}", manifest_fingerprint: "#{manifest.fingerprint}") { #{render_node.fetch(:expression)} } %>)
34
+ ]
35
+ end
36
+ end
37
+
38
+ def marker_replacements(source)
39
+ frontend_tag_plan_for_instrumentation
40
+ .filter_map { |tag| root_marker_replacement(source, tag) }
41
+ end
42
+
43
+ def frontend_tag_plan_for_instrumentation
44
+ return manifest.frontend_tag_plan.select { |entry| %w[fragment_root page_root render_site].include?(entry.fetch(:kind)) } if manifest.parse.fetch(:ok)
45
+
46
+ manifest.recovery_frontend_tag_plan.select { |entry| %w[fragment_root page_root].include?(entry.fetch(:kind)) }
47
+ end
48
+
49
+ def root_marker_replacement(source, tag)
50
+ return helper_marker_replacement(source, tag) if helper_lowered_tag?(tag)
51
+
52
+ offset = offset_for_location(source, tag.fetch(:location).fetch(:start))
53
+ open_tag_end = source.index(">", offset)
54
+ return unless open_tag_end
55
+
56
+ open_tag = source[offset...open_tag_end]
57
+ return if tag.fetch(:attributes).any? { |attribute| open_tag.include?(%(#{attribute.fetch(:name)}=)) }
58
+
59
+ insert_at = fragment_root_insert_offset(source, offset, tag.fetch(:tag_name))
60
+ [insert_at, insert_at, " #{attributes_source(tag.fetch(:attributes))}"]
61
+ end
62
+
63
+ def helper_marker_replacement(source, tag)
64
+ return unless tag_helper_source?(tag)
65
+
66
+ offset = offset_for_location(source, tag.fetch(:location).fetch(:start))
67
+ erb_end = source.index("%>", offset)
68
+ return unless erb_end
69
+
70
+ opening = source[offset...erb_end]
71
+ return if tag.fetch(:attributes).any? { |attribute| opening.include?(attribute.fetch(:name)) }
72
+
73
+ block_match = /\s+do(?:\s*\|[^|]*\|)?\s*\z/.match(opening)
74
+ return unless block_match
75
+
76
+ before_block = opening[0...block_match.begin(0)].rstrip
77
+ insert_at = offset + block_match.begin(0)
78
+ [
79
+ insert_at,
80
+ insert_at,
81
+ "#{helper_attribute_separator(before_block)}#{helper_attributes_source(tag.fetch(:attributes))}"
82
+ ]
83
+ end
84
+
85
+ def helper_lowered_tag?(tag)
86
+ tag.fetch(:element_source) != "HTML"
87
+ end
88
+
89
+ def tag_helper_source?(tag)
90
+ [
91
+ "ActionView::Helpers::TagHelper#tag",
92
+ "ActionView::Helpers::TagHelper#content_tag"
93
+ ].include?(tag.fetch(:element_source))
94
+ end
95
+
96
+ def helper_attribute_separator(before_block)
97
+ return ", " if before_block.match?(/\bcontent_tag\b/)
98
+
99
+ tag_call = before_block.match(/<%=?\s*tag\.[a-zA-Z_][a-zA-Z0-9_:-]*/)
100
+ return " " if tag_call && before_block[tag_call.end(0)..].to_s.strip.empty?
101
+
102
+ ", "
103
+ end
104
+
105
+ def helper_attributes_source(attributes)
106
+ attributes.map do |attribute|
107
+ %(#{attribute.fetch(:name).inspect} => #{ruby_attribute_value(attribute.fetch(:value))})
108
+ end.join(", ")
109
+ end
110
+
111
+ def ruby_attribute_value(value)
112
+ if (match = /\A<%=\s*(.*?)\s*%>\z/.match(value.to_s))
113
+ match[1]
114
+ else
115
+ value.inspect
116
+ end
117
+ end
118
+
119
+ def fragment_root_insert_offset(source, offset, tag_name)
120
+ match = /\A<\s*#{Regexp.escape(tag_name)}\b/i.match(source[offset..])
121
+ raise "could not locate fragment root tag for #{manifest.path}" unless match
122
+
123
+ offset + match.end(0)
124
+ end
125
+
126
+ def attributes_source(attributes)
127
+ attributes.map { |attribute| %(#{attribute.fetch(:name)}="#{attribute.fetch(:value)}") }.join(" ")
128
+ end
129
+
130
+ def apply_replacements(source, replacements)
131
+ replacements.sort_by { |start_offset, end_offset, _replacement| [-start_offset, -end_offset] }.each_with_object(source.dup) do |(start_offset, end_offset, replacement), result|
132
+ result[start_offset...end_offset] = replacement
133
+ end
134
+ end
135
+
136
+ def offset_for_location(source, position)
137
+ line_offsets(source).fetch(position.fetch(:line) - 1) + position.fetch(:column)
138
+ end
139
+
140
+ def line_offsets(source)
141
+ offsets = [0]
142
+ source.each_line(chomp: false).with_index do |line, index|
143
+ offsets[index + 1] = offsets[index] + line.bytesize
144
+ end
145
+ offsets
146
+ end
147
+ end
148
+ end
149
+ end