upkeep-rails 0.2.5-aarch64-linux-gnu

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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. metadata +327 -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,339 @@
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
+ DELIVERY_ERROR = "delivery_error.upkeep"
10
+
11
+ Stream = Data.define(
12
+ :action,
13
+ :target,
14
+ :target_selector,
15
+ :html,
16
+ :html_digest,
17
+ :identity_signature,
18
+ :shared_stream_name,
19
+ :subscriber_ids,
20
+ :recipients,
21
+ :matched_dependency_keys,
22
+ :deoptimization_reason,
23
+ :render_duration_ms,
24
+ :request_id
25
+ ) do
26
+ def to_html
27
+ if action == "refresh"
28
+ attributes = %(action="refresh" method="morph" scroll="preserve")
29
+ attributes = %(#{attributes} request-id="#{CGI.escapeHTML(request_id)}") if request_id
30
+ return %(<turbo-stream #{attributes}></turbo-stream>)
31
+ end
32
+
33
+ attributes = %(action="#{CGI.escapeHTML(action)}" targets="#{CGI.escapeHTML(target_selector)}")
34
+ attributes = %(#{attributes} method="morph") if morph_action?
35
+ return %(<turbo-stream #{attributes}></turbo-stream>) if action == "remove"
36
+
37
+ %(<turbo-stream #{attributes}><template>#{html}</template></turbo-stream>)
38
+ end
39
+
40
+ def rendered?
41
+ !%w[remove refresh].include?(action)
42
+ end
43
+
44
+ def morph_action?
45
+ %w[replace update].include?(action)
46
+ end
47
+
48
+ def for_subscriber?(subscriber_id)
49
+ subscriber_ids.include?(subscriber_id)
50
+ end
51
+
52
+ def for_subscription?(subscription_id)
53
+ recipients.any? { |recipient_subscription_id, _subscriber_id| recipient_subscription_id == subscription_id }
54
+ end
55
+
56
+ def report
57
+ {
58
+ action: action,
59
+ target: target.to_h,
60
+ target_selector: target_selector,
61
+ identity_signature: identity_signature,
62
+ shared_stream_name: shared_stream_name,
63
+ html_digest: html_digest,
64
+ subscriber_ids: subscriber_ids,
65
+ subscription_ids: recipients.map(&:first).uniq,
66
+ matched_dependency_keys: matched_dependency_keys,
67
+ deoptimization_reason: deoptimization_reason,
68
+ render_duration_ms: render_duration_ms,
69
+ request_id: request_id
70
+ }
71
+ end
72
+ end
73
+
74
+ Envelope = Data.define(:subscriber_id, :streams, :stream_name) do
75
+ def self.subscriber(subscriber_id, streams)
76
+ new(subscriber_id, streams, nil)
77
+ end
78
+
79
+ def self.subscription(subscription_id, subscriber_id, streams)
80
+ new(subscriber_id, streams, ActionCableAdapter.subscription_stream_name_for(subscription_id))
81
+ end
82
+
83
+ def self.shared(stream_name, streams)
84
+ new("shared:#{stream_name}", streams, stream_name)
85
+ end
86
+
87
+ def body
88
+ seen_refresh_tags = {}
89
+ streams.filter_map do |stream|
90
+ tag = stream.to_html
91
+ next tag unless stream.action == "refresh"
92
+ next if seen_refresh_tags.key?(tag)
93
+
94
+ seen_refresh_tags[tag] = true
95
+ tag
96
+ end.join("\n")
97
+ end
98
+
99
+ def report
100
+ {
101
+ subscriber_id: subscriber_id,
102
+ streams: streams.map(&:report)
103
+ }
104
+ end
105
+ end
106
+
107
+ Batch = Data.define(:streams) do
108
+ def envelopes
109
+ return [] if streams.empty?
110
+
111
+ shared_envelopes + subscriber_envelopes
112
+ end
113
+
114
+ def envelope_for(subscriber_id)
115
+ Envelope.subscriber(subscriber_id, streams.select { |stream| stream.for_subscriber?(subscriber_id) })
116
+ end
117
+
118
+ def report
119
+ {
120
+ streams: streams.map(&:report),
121
+ envelopes: envelopes.map(&:report)
122
+ }
123
+ end
124
+
125
+ private
126
+
127
+ def shared_envelopes
128
+ streams
129
+ .select(&:shared_stream_name)
130
+ .group_by(&:shared_stream_name)
131
+ .map { |stream_name, shared_streams| Envelope.shared(stream_name, shared_streams) }
132
+ end
133
+
134
+ def subscriber_envelopes
135
+ direct_streams = streams.reject(&:shared_stream_name)
136
+ direct_streams
137
+ .flat_map(&:recipients)
138
+ .uniq
139
+ .group_by(&:first)
140
+ .sort_by { |subscription_id, _recipients| subscription_id.to_s }
141
+ .map do |subscription_id, recipients|
142
+ Envelope.subscription(
143
+ subscription_id,
144
+ recipients.first.last,
145
+ direct_streams.select { |stream| stream.for_subscription?(subscription_id) }
146
+ )
147
+ end
148
+ end
149
+ end
150
+
151
+ def build(plan)
152
+ build_many([plan])
153
+ end
154
+
155
+ def build_many(plans)
156
+ payload = {
157
+ plans: plans.size,
158
+ planned_targets: plans.sum { |plan| plan.targets.size }
159
+ }
160
+
161
+ ActiveSupport::Notifications.instrument("build_turbo_streams.upkeep", payload) do
162
+ streams = plans.flat_map { |plan| stream_targets(plan.targets, request_id: plan.request_id) }.compact
163
+ batch = Batch.new(merge_streams(streams))
164
+ payload.merge!(payload_for(batch, rendered_streams: streams))
165
+ batch
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ def payload_for(batch, rendered_streams:)
172
+ envelopes = batch.envelopes
173
+
174
+ {
175
+ streams: batch.streams.size,
176
+ envelopes: envelopes.size,
177
+ actions: batch.streams.map(&:action).tally,
178
+ deoptimizations: batch.streams.filter_map(&:deoptimization_reason).tally,
179
+ renders: rendered_streams.count(&:rendered?),
180
+ render_duration_ms: sum_render_duration(rendered_streams),
181
+ payload_bytes: envelopes.sum { |envelope| envelope.body.bytesize }
182
+ }
183
+ end
184
+
185
+ def stream_targets(planned_targets, request_id:)
186
+ return [] if planned_targets.empty?
187
+ return [stream_for(planned_targets.first, request_id: request_id)] if planned_targets.one?
188
+
189
+ planned_targets.group_by { |planned_target| render_group_key(planned_target) }.map do |_key, targets|
190
+ stream_for(
191
+ targets.first,
192
+ subscriber_ids: targets.flat_map(&:subscriber_ids),
193
+ recipients: targets.flat_map(&:recipients),
194
+ matched_dependency_keys: targets.flat_map(&:matched_dependency_keys),
195
+ request_id: request_id
196
+ )
197
+ end
198
+ end
199
+
200
+ # The write that produced these changes has already committed; an isolated render/targeting
201
+ # failure for one target must never propagate back into the writer's request. Rescue per
202
+ # target, surface the failure via instrumentation, and keep delivering the other targets.
203
+ def stream_for(planned_target, request_id:, subscriber_ids: planned_target.subscriber_ids, recipients: planned_target.recipients, matched_dependency_keys: planned_target.matched_dependency_keys)
204
+ build_stream(planned_target, subscriber_ids: subscriber_ids, recipients: recipients, matched_dependency_keys: matched_dependency_keys, request_id: request_id)
205
+ rescue StandardError => error
206
+ ActiveSupport::Notifications.instrument(
207
+ DELIVERY_ERROR,
208
+ target: planned_target.target.to_h,
209
+ action: planned_target.action,
210
+ subscription_id: planned_target.subscription_id,
211
+ subscriber_ids: subscriber_ids.uniq.sort_by(&:to_s),
212
+ error_class: error.class.name,
213
+ error_message: error.message
214
+ )
215
+ nil
216
+ end
217
+
218
+ def build_stream(planned_target, subscriber_ids:, recipients:, matched_dependency_keys:, request_id:)
219
+ html, render_duration_ms = render_target(planned_target)
220
+
221
+ Stream.new(
222
+ planned_target.action,
223
+ planned_target.target,
224
+ target_selector_for(planned_target.target),
225
+ html,
226
+ Targeting::Extraction.digest_html(html),
227
+ planned_target.identity_signature,
228
+ shared_stream_name_for(planned_target, subscriber_ids: subscriber_ids),
229
+ subscriber_ids.uniq.sort_by(&:to_s),
230
+ recipients.uniq.sort_by { |subscription_id, subscriber_id| [subscription_id.to_s, subscriber_id.to_s] },
231
+ matched_dependency_keys.uniq,
232
+ planned_target.deoptimization_reason,
233
+ render_duration_ms,
234
+ # Only Turbo's refresh stream action consults request-id; keeping it nil on
235
+ # rendered streams lets identical payloads from different requests dedup.
236
+ planned_target.action == "refresh" ? request_id : nil
237
+ )
238
+ end
239
+
240
+ def render_target(planned_target)
241
+ return ["", 0.0] if %w[remove refresh].include?(planned_target.action)
242
+
243
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
244
+ html = planned_target.render
245
+ finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
246
+ [html, ((finished_at - started_at) * 1000.0).round(3)]
247
+ end
248
+
249
+ def render_group_key(planned_target)
250
+ [
251
+ planned_target.action,
252
+ planned_target.target.kind,
253
+ planned_target.target.id,
254
+ planned_target.identity_signature,
255
+ planned_target.sharing_signature,
256
+ planned_target.deployment_signature,
257
+ SharedStreams.signature_for(planned_target.recipe),
258
+ planned_target.deoptimization_reason
259
+ ]
260
+ end
261
+
262
+ # request_id splits the key only for refresh streams (nil everywhere else):
263
+ # refreshes from distinct requests must stay separate tags so each originating
264
+ # client skips only its own and still refreshes for the other request's change.
265
+ def merge_streams(streams)
266
+ streams.each_with_object({}) do |stream, indexed_streams|
267
+ key = [
268
+ stream.action,
269
+ stream.target.kind,
270
+ stream.target.id,
271
+ stream.identity_signature,
272
+ stream.shared_stream_name,
273
+ stream.html_digest,
274
+ stream.deoptimization_reason,
275
+ stream.request_id
276
+ ]
277
+ indexed_streams[key] = merge_stream(indexed_streams[key], stream)
278
+ end.values
279
+ end
280
+
281
+ def merge_stream(existing, stream)
282
+ return stream unless existing
283
+
284
+ Stream.new(
285
+ existing.action,
286
+ existing.target,
287
+ existing.target_selector,
288
+ existing.html,
289
+ existing.html_digest,
290
+ existing.identity_signature,
291
+ existing.shared_stream_name,
292
+ (existing.subscriber_ids + stream.subscriber_ids).uniq.sort_by(&:to_s),
293
+ (existing.recipients + stream.recipients).uniq.sort_by { |subscription_id, subscriber_id| [subscription_id.to_s, subscriber_id.to_s] },
294
+ (existing.matched_dependency_keys + stream.matched_dependency_keys).uniq,
295
+ existing.deoptimization_reason,
296
+ (existing.render_duration_ms + stream.render_duration_ms).round(3),
297
+ existing.request_id
298
+ )
299
+ end
300
+
301
+ def sum_render_duration(streams)
302
+ streams.sum(&:render_duration_ms).round(3)
303
+ end
304
+
305
+ def shared_stream_name_for(planned_target, subscriber_ids:)
306
+ return unless planned_target.sharing_signature
307
+ return unless subscriber_ids.uniq.size > 1
308
+
309
+ SharedStreams.stream_name(
310
+ target: planned_target.shared_stream_target,
311
+ identity_signature: planned_target.identity_signature,
312
+ sharing_signature: planned_target.sharing_signature,
313
+ deployment_signature: planned_target.deployment_signature
314
+ )
315
+ end
316
+
317
+ def target_selector_for(target)
318
+ case target.kind
319
+ when "page"
320
+ %([data-upkeep-page-frame="#{css_escape(target.id)}"])
321
+ when "fragment"
322
+ %([data-upkeep-frame="#{css_escape(target.id)}"])
323
+ when "render_site"
324
+ %([data-upkeep-render-site="#{css_escape(target.id)}"])
325
+ when "dom_id"
326
+ %[##{css_escape(target.id)}]
327
+ when "turbo_frame"
328
+ %[turbo-frame[id="#{css_escape(target.id)}"]]
329
+ else
330
+ raise "unknown delivery target kind: #{target.kind.inspect}"
331
+ end
332
+ end
333
+
334
+ def css_escape(value)
335
+ value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
336
+ end
337
+ end
338
+ end
339
+ 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"