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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Upkeep
6
+ module Delivery
7
+ class Transport
8
+ Outcome = Data.define(
9
+ :subscriber_id,
10
+ :status,
11
+ :attempts,
12
+ :queue_depth,
13
+ :envelope_digest,
14
+ :error_class,
15
+ :error_message
16
+ ) do
17
+ def delivered?
18
+ status == :delivered
19
+ end
20
+
21
+ def report
22
+ {
23
+ subscriber_id: subscriber_id,
24
+ status: status,
25
+ attempts: attempts,
26
+ queue_depth: queue_depth,
27
+ envelope_digest: envelope_digest,
28
+ error_class: error_class,
29
+ error_message: error_message
30
+ }.compact
31
+ end
32
+ end
33
+
34
+ DispatchReport = Data.define(:outcomes) do
35
+ def summary
36
+ outcomes.each_with_object(Hash.new(0)) { |outcome, counts| counts[outcome.status] += 1 }
37
+ end
38
+
39
+ def report
40
+ {
41
+ summary: summary,
42
+ outcomes: outcomes.map(&:report)
43
+ }
44
+ end
45
+ end
46
+
47
+ Cleanup = Data.define(:subscriber_id, :status, :dropped_envelopes)
48
+ RetryItem = Data.define(:envelope, :attempts, :error)
49
+
50
+ class Connection
51
+ attr_reader :subscriber_id
52
+
53
+ def initialize(subscriber_id:, adapter:, max_queue_size:, retry_limit:)
54
+ @subscriber_id = subscriber_id
55
+ @adapter = adapter
56
+ @max_queue_size = max_queue_size
57
+ @retry_limit = retry_limit
58
+ @queue = []
59
+ end
60
+
61
+ def deliver(envelope)
62
+ deliver_envelope(envelope, attempts: 0)
63
+ end
64
+
65
+ def retry_pending
66
+ pending = @queue
67
+ @queue = []
68
+
69
+ pending.map { |item| deliver_envelope(item.envelope, attempts: item.attempts) }
70
+ end
71
+
72
+ def disconnect
73
+ dropped = @queue.size
74
+ @queue = []
75
+ dropped
76
+ end
77
+
78
+ def queue_depth
79
+ @queue.size
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :adapter, :max_queue_size, :retry_limit
85
+
86
+ def deliver_envelope(envelope, attempts:)
87
+ next_attempt = attempts + 1
88
+ adapter.deliver(envelope)
89
+
90
+ outcome(:delivered, envelope, attempts: next_attempt)
91
+ rescue StandardError => error
92
+ if next_attempt >= retry_limit
93
+ outcome(:dropped_retry_exhausted, envelope, attempts: next_attempt, error: error)
94
+ elsif queue_depth >= max_queue_size
95
+ outcome(:backpressured, envelope, attempts: next_attempt, error: error)
96
+ else
97
+ @queue << RetryItem.new(envelope, next_attempt, error)
98
+ outcome(:queued_retry, envelope, attempts: next_attempt, error: error)
99
+ end
100
+ end
101
+
102
+ def outcome(status, envelope, attempts:, error: nil)
103
+ Outcome.new(
104
+ subscriber_id,
105
+ status,
106
+ attempts,
107
+ queue_depth,
108
+ Transport.envelope_digest(envelope),
109
+ error&.class&.name,
110
+ error&.message
111
+ )
112
+ end
113
+ end
114
+
115
+ class << self
116
+ def envelope_digest(envelope)
117
+ Digest::SHA256.hexdigest(envelope.body)
118
+ end
119
+ end
120
+
121
+ def initialize(max_queue_size: 100, retry_limit: 3)
122
+ @max_queue_size = max_queue_size
123
+ @retry_limit = retry_limit
124
+ @connections = {}
125
+ end
126
+
127
+ def connect(subscriber_id:, adapter:)
128
+ Connection.new(
129
+ subscriber_id: subscriber_id,
130
+ adapter: adapter,
131
+ max_queue_size: max_queue_size,
132
+ retry_limit: retry_limit
133
+ ).tap { |connection| connections[subscriber_id] = connection }
134
+ end
135
+
136
+ def disconnect(subscriber_id)
137
+ connection = connections.delete(subscriber_id)
138
+ dropped = connection&.disconnect || 0
139
+
140
+ Cleanup.new(subscriber_id, :disconnected, dropped)
141
+ end
142
+
143
+ def deliver(batch)
144
+ DispatchReport.new(batch.envelopes.map { |envelope| deliver_envelope(envelope) })
145
+ end
146
+
147
+ def retry_pending(subscriber_id: nil)
148
+ selected_connections = if subscriber_id
149
+ Array(connections[subscriber_id])
150
+ else
151
+ connections.values
152
+ end
153
+
154
+ DispatchReport.new(selected_connections.flat_map(&:retry_pending))
155
+ end
156
+
157
+ def connected?(subscriber_id)
158
+ connections.key?(subscriber_id)
159
+ end
160
+
161
+ def summary
162
+ {
163
+ connections: connections.size,
164
+ queued_envelopes: connections.values.sum(&:queue_depth),
165
+ max_queue_size: max_queue_size,
166
+ retry_limit: retry_limit
167
+ }
168
+ end
169
+
170
+ private
171
+
172
+ attr_reader :connections, :max_queue_size, :retry_limit
173
+
174
+ def deliver_envelope(envelope)
175
+ connection = connections[envelope.subscriber_id]
176
+ return disconnected_outcome(envelope) unless connection
177
+
178
+ connection.deliver(envelope)
179
+ end
180
+
181
+ def disconnected_outcome(envelope)
182
+ Outcome.new(
183
+ envelope.subscriber_id,
184
+ :disconnected,
185
+ 0,
186
+ 0,
187
+ self.class.envelope_digest(envelope),
188
+ nil,
189
+ nil
190
+ )
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "cgi"
5
+
6
+ module Upkeep
7
+ module Delivery
8
+ class TurboStreams
9
+ Stream = Data.define(
10
+ :action,
11
+ :target,
12
+ :target_selector,
13
+ :html,
14
+ :html_digest,
15
+ :identity_signature,
16
+ :shared_stream_name,
17
+ :subscriber_ids,
18
+ :matched_dependency_keys,
19
+ :deoptimization_reason,
20
+ :render_duration_ms
21
+ ) do
22
+ def to_html
23
+ attributes = %(action="#{CGI.escapeHTML(action)}" targets="#{CGI.escapeHTML(target_selector)}")
24
+ return %(<turbo-stream #{attributes}></turbo-stream>) if action == "remove"
25
+
26
+ %(<turbo-stream #{attributes}><template>#{html}</template></turbo-stream>)
27
+ end
28
+
29
+ def rendered?
30
+ action != "remove"
31
+ end
32
+
33
+ def for_subscriber?(subscriber_id)
34
+ subscriber_ids.include?(subscriber_id)
35
+ end
36
+
37
+ def report
38
+ {
39
+ action: action,
40
+ target: target.to_h,
41
+ target_selector: target_selector,
42
+ identity_signature: identity_signature,
43
+ shared_stream_name: shared_stream_name,
44
+ html_digest: html_digest,
45
+ subscriber_ids: subscriber_ids,
46
+ matched_dependency_keys: matched_dependency_keys,
47
+ deoptimization_reason: deoptimization_reason,
48
+ render_duration_ms: render_duration_ms
49
+ }
50
+ end
51
+ end
52
+
53
+ Envelope = Data.define(:subscriber_id, :streams, :stream_name) do
54
+ def self.subscriber(subscriber_id, streams)
55
+ new(subscriber_id, streams, nil)
56
+ end
57
+
58
+ def self.shared(stream_name, streams)
59
+ new("shared:#{stream_name}", streams, stream_name)
60
+ end
61
+
62
+ def body
63
+ streams.map(&:to_html).join("\n")
64
+ end
65
+
66
+ def report
67
+ {
68
+ subscriber_id: subscriber_id,
69
+ streams: streams.map(&:report)
70
+ }
71
+ end
72
+ end
73
+
74
+ Batch = Data.define(:streams) do
75
+ def envelopes
76
+ return [] if streams.empty?
77
+
78
+ direct_subscriber_id = single_direct_subscriber_id
79
+ return [Envelope.subscriber(direct_subscriber_id, streams)] if direct_subscriber_id
80
+
81
+ shared_envelopes + subscriber_envelopes
82
+ end
83
+
84
+ def envelope_for(subscriber_id)
85
+ Envelope.subscriber(subscriber_id, streams.select { |stream| stream.for_subscriber?(subscriber_id) })
86
+ end
87
+
88
+ def report
89
+ {
90
+ streams: streams.map(&:report),
91
+ envelopes: envelopes.map(&:report)
92
+ }
93
+ end
94
+
95
+ private
96
+
97
+ def single_direct_subscriber_id
98
+ return if streams.any?(&:shared_stream_name)
99
+
100
+ subscriber_ids = streams.flat_map(&:subscriber_ids).uniq
101
+ subscriber_ids.first if subscriber_ids.one?
102
+ end
103
+
104
+ def shared_envelopes
105
+ streams
106
+ .select(&:shared_stream_name)
107
+ .group_by(&:shared_stream_name)
108
+ .map { |stream_name, shared_streams| Envelope.shared(stream_name, shared_streams) }
109
+ end
110
+
111
+ def subscriber_envelopes
112
+ direct_streams = streams.reject(&:shared_stream_name)
113
+ direct_streams
114
+ .flat_map(&:subscriber_ids)
115
+ .uniq
116
+ .sort_by(&:to_s)
117
+ .map { |subscriber_id| Envelope.subscriber(subscriber_id, direct_streams.select { |stream| stream.for_subscriber?(subscriber_id) }) }
118
+ end
119
+ end
120
+
121
+ def build(plan)
122
+ build_many([plan])
123
+ end
124
+
125
+ def build_many(plans)
126
+ payload = {
127
+ plans: plans.size,
128
+ planned_targets: plans.sum { |plan| plan.targets.size }
129
+ }
130
+
131
+ ActiveSupport::Notifications.instrument("build_turbo_streams.upkeep", payload) do
132
+ streams = plans.flat_map { |plan| stream_targets(plan.targets) }
133
+ batch = Batch.new(merge_streams(streams))
134
+ payload.merge!(payload_for(batch, rendered_streams: streams))
135
+ batch
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def payload_for(batch, rendered_streams:)
142
+ envelopes = batch.envelopes
143
+
144
+ {
145
+ streams: batch.streams.size,
146
+ envelopes: envelopes.size,
147
+ actions: batch.streams.map(&:action).tally,
148
+ deoptimizations: batch.streams.filter_map(&:deoptimization_reason).tally,
149
+ renders: rendered_streams.count(&:rendered?),
150
+ render_duration_ms: sum_render_duration(rendered_streams),
151
+ payload_bytes: envelopes.sum { |envelope| envelope.body.bytesize }
152
+ }
153
+ end
154
+
155
+ def stream_targets(planned_targets)
156
+ return [] if planned_targets.empty?
157
+ return [stream_for(planned_targets.first)] if planned_targets.one?
158
+
159
+ planned_targets.group_by { |planned_target| render_group_key(planned_target) }.map do |_key, targets|
160
+ stream_for(
161
+ targets.first,
162
+ subscriber_ids: targets.flat_map(&:subscriber_ids),
163
+ matched_dependency_keys: targets.flat_map(&:matched_dependency_keys)
164
+ )
165
+ end
166
+ end
167
+
168
+ def stream_for(planned_target, subscriber_ids: planned_target.subscriber_ids, matched_dependency_keys: planned_target.matched_dependency_keys)
169
+ html, render_duration_ms = render_target(planned_target)
170
+
171
+ Stream.new(
172
+ planned_target.action,
173
+ planned_target.target,
174
+ target_selector_for(planned_target.target),
175
+ html,
176
+ Targeting::Extraction.digest_html(html),
177
+ planned_target.identity_signature,
178
+ shared_stream_name_for(planned_target, subscriber_ids: subscriber_ids),
179
+ subscriber_ids.uniq.sort_by(&:to_s),
180
+ matched_dependency_keys.uniq,
181
+ planned_target.deoptimization_reason,
182
+ render_duration_ms
183
+ )
184
+ end
185
+
186
+ def render_target(planned_target)
187
+ return ["", 0.0] if planned_target.action == "remove"
188
+
189
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
190
+ html = planned_target.render
191
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
192
+ [html, ((finished_at - started_at) * 1000.0).round(3)]
193
+ end
194
+
195
+ def render_group_key(planned_target)
196
+ [
197
+ planned_target.action,
198
+ planned_target.target.kind,
199
+ planned_target.target.id,
200
+ planned_target.identity_signature,
201
+ planned_target.sharing_signature,
202
+ SharedStreams.signature_for(planned_target.recipe),
203
+ planned_target.deoptimization_reason
204
+ ]
205
+ end
206
+
207
+ def merge_streams(streams)
208
+ streams.each_with_object({}) do |stream, indexed_streams|
209
+ key = [
210
+ stream.action,
211
+ stream.target.kind,
212
+ stream.target.id,
213
+ stream.identity_signature,
214
+ stream.shared_stream_name,
215
+ stream.html_digest,
216
+ stream.deoptimization_reason
217
+ ]
218
+ indexed_streams[key] = merge_stream(indexed_streams[key], stream)
219
+ end.values
220
+ end
221
+
222
+ def merge_stream(existing, stream)
223
+ return stream unless existing
224
+
225
+ Stream.new(
226
+ existing.action,
227
+ existing.target,
228
+ existing.target_selector,
229
+ existing.html,
230
+ existing.html_digest,
231
+ existing.identity_signature,
232
+ existing.shared_stream_name,
233
+ (existing.subscriber_ids + stream.subscriber_ids).uniq.sort_by(&:to_s),
234
+ (existing.matched_dependency_keys + stream.matched_dependency_keys).uniq,
235
+ existing.deoptimization_reason,
236
+ (existing.render_duration_ms + stream.render_duration_ms).round(3)
237
+ )
238
+ end
239
+
240
+ def sum_render_duration(streams)
241
+ streams.sum(&:render_duration_ms).round(3)
242
+ end
243
+
244
+ def shared_stream_name_for(planned_target, subscriber_ids:)
245
+ return unless planned_target.sharing_signature
246
+ return unless subscriber_ids.uniq.size > 1
247
+
248
+ SharedStreams.stream_name(
249
+ target: planned_target.target,
250
+ identity_signature: planned_target.identity_signature,
251
+ sharing_signature: planned_target.sharing_signature
252
+ )
253
+ end
254
+
255
+ def target_selector_for(target)
256
+ case target.kind
257
+ when "page"
258
+ %([data-upkeep-page-frame="#{css_escape(target.id)}"])
259
+ when "fragment"
260
+ %([data-upkeep-frame="#{css_escape(target.id)}"])
261
+ when "render_site"
262
+ %[upkeep-render-site[data-upkeep-render-site="#{css_escape(target.id)}"]]
263
+ when "dom_id"
264
+ %[##{css_escape(target.id)}]
265
+ else
266
+ raise "unknown delivery target kind: #{target.kind.inspect}"
267
+ end
268
+ end
269
+
270
+ def css_escape(value)
271
+ value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delivery/turbo_streams"
4
+ require_relative "delivery/transport"
5
+ require_relative "delivery/action_cable_adapter"
6
+ require_relative "delivery/broadcast_transport"
7
+ require_relative "delivery/async_dispatcher"