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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_verifier"
4
+ require "securerandom"
5
+
6
+ module Upkeep
7
+ module Rails
8
+ module ActivationToken
9
+ PURPOSE = "upkeep-subscription-activation"
10
+
11
+ module_function
12
+
13
+ def generate(subscription)
14
+ subscription_id = subscription.respond_to?(:id) ? subscription.id : subscription
15
+ verifier.generate(
16
+ { "subscription_id" => subscription_id.to_s },
17
+ purpose: PURPOSE,
18
+ expires_in: Upkeep::Rails.configuration.activation_token_expires_in
19
+ )
20
+ end
21
+
22
+ def valid_for_subscription?(token, subscription_id)
23
+ payload = verifier.verified(token.to_s, purpose: PURPOSE)
24
+ token_subscription_id(payload) == subscription_id.to_s
25
+ end
26
+
27
+ def token_subscription_id(payload)
28
+ return unless payload.respond_to?(:[])
29
+
30
+ payload["subscription_id"] || payload[:subscription_id]
31
+ end
32
+
33
+ def verifier
34
+ rails_message_verifier || fallback_verifier
35
+ end
36
+
37
+ def rails_message_verifier
38
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application)
39
+ return unless ::Rails.application.respond_to?(:message_verifier)
40
+
41
+ ::Rails.application.message_verifier(PURPOSE)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ def fallback_verifier
47
+ @fallback_verifier ||= ActiveSupport::MessageVerifier.new(fallback_secret)
48
+ end
49
+
50
+ def fallback_secret
51
+ @fallback_secret ||= SecureRandom.hex(64)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+ require "active_support/notifications"
5
+
6
+ module Upkeep
7
+ module Rails
8
+ module Cable
9
+ class Channel < ::ActionCable::Channel::Base
10
+ SUBSCRIBE_NOTIFICATION = "subscribe_channel.upkeep"
11
+
12
+ # Liveness heartbeat: a connected page touches its subscription row on
13
+ # this interval, keeping updated_at fresh so opportunistic pruning only
14
+ # ever removes abandoned subscriptions. Invariant: connected =>
15
+ # touched at least every HEARTBEAT_INTERVAL => retained, so this must
16
+ # stay far below config.subscription_ttl (20 minutes vs a 24 hour
17
+ # default). It is a constant because ActionCable fixes periodic timer
18
+ # intervals at class load, before app configuration is readable.
19
+ HEARTBEAT_INTERVAL = 20 * 60
20
+
21
+ periodically :touch_upkeep_subscription, every: HEARTBEAT_INTERVAL
22
+
23
+ def subscribed
24
+ if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
25
+ instrumented_subscribe
26
+ else
27
+ subscribe_without_instrumentation
28
+ end
29
+ end
30
+
31
+ def unsubscribed
32
+ Upkeep::Rails.subscriptions.unregister(subscription_id)
33
+ rescue KeyError
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ # Cheap liveness touch (update_columns on the subscription row). Must
40
+ # never raise into the cable process; a missed heartbeat just leaves
41
+ # the subscription for a later beat or the opportunistic trim.
42
+ def touch_upkeep_subscription
43
+ Upkeep::Rails.subscriptions.touch(subscription_id)
44
+ nil
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def instrumented_subscribe
50
+ payload = { subscription_id: safe_subscription_id }
51
+ ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
52
+ subscribe_without_instrumentation(payload: payload)
53
+ end
54
+ end
55
+
56
+ def subscribe_without_instrumentation(payload: nil)
57
+ id = subscription_id
58
+ payload[:subscription_id] = id if payload
59
+ unless measure(payload, :activation_token_ms) { valid_activation_token?(id) }
60
+ return reject_upkeep_subscription(payload, "invalid_activation_token")
61
+ end
62
+
63
+ subscription = measure(payload, :fetch_ms) { Upkeep::Rails.subscriptions.fetch(id) }
64
+ authorized = measure(payload, :authorization_ms) { authorized_subscription?(subscription) }
65
+ unless authorized
66
+ return reject_upkeep_subscription(payload, "unauthorized_identity")
67
+ end
68
+
69
+ measure(payload, :activation_ms) { Upkeep::Rails.subscriptions.activate(id) }
70
+ stream_count = measure(payload, :stream_attach_ms) { attach_streams(subscription) }
71
+ payload[:stream_count] = stream_count if payload
72
+ rescue KeyError
73
+ reject_upkeep_subscription(payload, missing_activation_token? ? "missing_activation_token" : "missing_subscription")
74
+ rescue UnidentifiedSubscriber
75
+ reject_upkeep_subscription(payload, "unidentified_subscriber")
76
+ end
77
+
78
+ def reject_upkeep_subscription(payload, reason)
79
+ payload[:rejected] = true if payload
80
+ payload[:reject_reason] = reason if payload
81
+ log_subscription_rejection(reason)
82
+ reject
83
+ end
84
+
85
+ def attach_streams(subscription)
86
+ stream_from stream_name_for(subscription)
87
+ count = 1
88
+ shared_stream_names_for(subscription).each do |stream_name|
89
+ stream_from stream_name
90
+ count += 1
91
+ end
92
+ count
93
+ end
94
+
95
+ def measure(payload, key)
96
+ return yield unless payload
97
+
98
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ yield
100
+ ensure
101
+ payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3) if payload && started_at
102
+ end
103
+
104
+ def safe_subscription_id
105
+ subscription_id
106
+ rescue KeyError
107
+ nil
108
+ end
109
+
110
+ def subscription_id
111
+ params.fetch(:subscription_id)
112
+ end
113
+
114
+ def activation_token
115
+ params.fetch(:activation_token)
116
+ rescue KeyError
117
+ @missing_activation_token = true
118
+ raise
119
+ end
120
+
121
+ def missing_activation_token?
122
+ @missing_activation_token == true
123
+ end
124
+
125
+ def valid_activation_token?(id)
126
+ ActivationToken.valid_for_subscription?(activation_token, id)
127
+ end
128
+
129
+ def log_subscription_rejection(reason)
130
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
131
+
132
+ message = +"[upkeep] subscription rejected (#{reason}) subscription_id=#{safe_subscription_id.inspect}."
133
+ if reason == "missing_activation_token"
134
+ message << " If this started after upgrading upkeep-rails, " \
135
+ "refresh app/javascript/upkeep/subscription.js from the install generator."
136
+ end
137
+ ::Rails.logger.warn(message)
138
+ end
139
+
140
+ def authorized_subscription?(subscription)
141
+ return true if anonymous_public_subscription?(subscription)
142
+ return true unless metadata_value(subscription, :identity_mode)
143
+
144
+ SubscriberIdentity.derive_for_subscription(connection, subscription).subscriber_id == subscription.subscriber_id
145
+ end
146
+
147
+ def anonymous_public_subscription?(subscription)
148
+ metadata_value(subscription, :identity_mode) == SubscriberIdentity::ANONYMOUS_PUBLIC_MODE
149
+ end
150
+
151
+ def stream_name_for(subscription)
152
+ Delivery::ActionCableAdapter.subscription_stream_name_for(subscription.id)
153
+ end
154
+
155
+ def shared_stream_names_for(subscription)
156
+ metadata_value(subscription, :shared_stream_names) || []
157
+ end
158
+
159
+ def metadata_value(subscription, key)
160
+ subscription.metadata[key] || subscription.metadata[key.to_s]
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Upkeep
8
+ module Rails
9
+ module Cable
10
+ class UnidentifiedSubscriber < StandardError; end
11
+
12
+ Identity = Data.define(:subscriber_id, :stream_name, :components)
13
+ Decision = Data.define(:mode, :anonymous, :deopt_reason, :identity_sources, :identity_names)
14
+
15
+ class SubscribeContext
16
+ def initialize(connection)
17
+ @action_cable_connection = connection
18
+ end
19
+
20
+ def session
21
+ action_cable_request.session
22
+ end
23
+
24
+ def cookies
25
+ action_cable_request.cookies
26
+ end
27
+
28
+ def method_missing(name, *args, &block)
29
+ if action_cable_connection.respond_to?(name)
30
+ action_cable_connection.public_send(name, *args, &block)
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def respond_to_missing?(name, include_private = false)
37
+ action_cable_connection.respond_to?(name, false) || super
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :action_cable_connection
43
+
44
+ # ActionCable::Connection::Base keeps `request` private on Rails
45
+ # 7.1-8.x but exposes `env` as a public attr_reader; the adapterized
46
+ # connection on Rails main exposes both publicly. Prefer a public
47
+ # `request`, otherwise build one from the public Rack env the same
48
+ # way Action Cable does. Never reach into private connection API.
49
+ def action_cable_request
50
+ @action_cable_request ||=
51
+ if action_cable_connection.respond_to?(:request)
52
+ action_cable_connection.request
53
+ elsif action_cable_connection.respond_to?(:env)
54
+ ::ActionDispatch::Request.new(rails_rack_env)
55
+ else
56
+ raise UnidentifiedSubscriber,
57
+ "ActionCable connection exposes neither a public request nor a public env"
58
+ end
59
+ end
60
+
61
+ def rails_rack_env
62
+ env = action_cable_connection.env
63
+ return env unless defined?(::Rails.application) && ::Rails.application
64
+
65
+ ::Rails.application.env_config.merge(env)
66
+ end
67
+ end
68
+
69
+ module SubscriberIdentity
70
+ ANONYMOUS_PUBLIC_MODE = "anonymous_public"
71
+ IDENTIFIED_MODE = "identified"
72
+ DECLARATION_REQUIRED_SOURCES = %w[Current.user current_attribute warden_user].freeze
73
+
74
+ module_function
75
+
76
+ def derive(connection)
77
+ derive_all(connection).last
78
+ end
79
+
80
+ def derive_all(connection)
81
+ components = subscribe_components(connection, configuration.identity_definitions)
82
+ raise UnidentifiedSubscriber, "ActionCable connection has no declared Upkeep identities" if components.empty?
83
+
84
+ [for_components(components)]
85
+ end
86
+
87
+ def derive_for_subscription(connection, subscription)
88
+ names = metadata_value(subscription, :identity_names) || []
89
+ definitions = names.map { |name| configuration.identity_definition(name) }
90
+ components = subscribe_components(connection, definitions)
91
+ raise UnidentifiedSubscriber, "ActionCable connection did not resolve subscription identities #{names.inspect}" if components.empty?
92
+
93
+ for_components(components)
94
+ end
95
+
96
+ def derive_from_request(request, recorder:, decision: decision_for(request, recorder: recorder))
97
+ components = if decision.anonymous
98
+ anonymous_components
99
+ else
100
+ recorder_components(recorder)
101
+ end
102
+
103
+ if components.empty?
104
+ raise UnidentifiedSubscriber,
105
+ "subscription has identity dependencies but no declared Upkeep identity mapping"
106
+ end
107
+
108
+ for_components(components)
109
+ end
110
+
111
+ def decision_for(_request = nil, recorder:)
112
+ configured_dependencies = configured_identity_dependencies(recorder)
113
+ undeclared_dependencies = undeclared_identity_dependencies(recorder)
114
+
115
+ if configured_dependencies.any?
116
+ Decision.new(
117
+ IDENTIFIED_MODE,
118
+ false,
119
+ "identity_dependencies_present",
120
+ configured_dependencies.map { |definition, _dependency| definition.source.to_s }.uniq.sort,
121
+ configured_dependencies.map { |definition, _dependency| definition.name.to_s }.uniq.sort
122
+ )
123
+ elsif undeclared_dependencies.any?
124
+ Decision.new(
125
+ IDENTIFIED_MODE,
126
+ false,
127
+ "identity_setup_required",
128
+ undeclared_dependencies.map { |dependency| dependency.source.to_s }.uniq.sort,
129
+ []
130
+ )
131
+ else
132
+ Decision.new(
133
+ ANONYMOUS_PUBLIC_MODE,
134
+ true,
135
+ nil,
136
+ [],
137
+ []
138
+ )
139
+ end
140
+ end
141
+
142
+ def identifier_components(connection)
143
+ identifiers = Array(connection.identifiers)
144
+ identifiers.map { |name| component_for(name, connection.public_send(name)) }
145
+ end
146
+
147
+ def for_identifiers(identifiers)
148
+ for_components(identifiers.map { |name, value| component_for(name, value) })
149
+ end
150
+
151
+ def for_components(components)
152
+ canonical_bytes = JSON.generate(components.sort_by { |component| component.fetch(:name) })
153
+ subscriber_id = "action_cable:#{Digest::SHA256.hexdigest(canonical_bytes)}"
154
+
155
+ Identity.new(
156
+ subscriber_id,
157
+ Delivery::ActionCableAdapter.stream_name_for(subscriber_id),
158
+ components
159
+ )
160
+ end
161
+
162
+ def recorder_components(recorder)
163
+ components_by_name = Hash.new { |hash, key| hash[key] = [] }
164
+ configured_identity_dependencies(recorder).each do |definition, dependency|
165
+ component = component_for_dependency(definition, dependency)
166
+ components_by_name[definition.name] << component if component
167
+ end
168
+
169
+ components_by_name.map do |name, components|
170
+ unique_components = components.uniq
171
+ if unique_components.size > 1
172
+ raise UnidentifiedSubscriber, "captured identity :#{name} changed during request"
173
+ end
174
+
175
+ unique_components.first
176
+ end.compact
177
+ end
178
+
179
+ def anonymous_components
180
+ [ scalar_component(:anonymous_public_subscription, SecureRandom.uuid) ]
181
+ end
182
+
183
+ def identity_dependencies(recorder)
184
+ return [] unless recorder
185
+
186
+ recorder.graph.dependency_nodes
187
+ .map(&:payload)
188
+ .select { |dependency| Dependencies.partitioning_identity?(dependency) }
189
+ .uniq(&:cache_key)
190
+ end
191
+
192
+ def configured_identity_dependencies(recorder)
193
+ definitions = configuration.identity_definitions
194
+ return [] if definitions.empty?
195
+
196
+ identity_dependencies(recorder).flat_map do |dependency|
197
+ definitions.select { |definition| definition.matches_dependency?(dependency) }
198
+ .reject { |definition| definition.absent_dependency?(dependency) }
199
+ .map { |definition| [definition, dependency] }
200
+ end
201
+ end
202
+
203
+ def undeclared_identity_dependencies(recorder)
204
+ identity_dependencies(recorder).select do |dependency|
205
+ declaration_required_dependency?(dependency) &&
206
+ configured_identity_dependencies_for_dependency(dependency).empty?
207
+ end
208
+ end
209
+
210
+ def configured_identity_dependencies_for_dependency(dependency)
211
+ configuration.identity_definitions.select { |definition| definition.matches_dependency?(dependency) }
212
+ end
213
+
214
+ def declaration_required_dependency?(dependency)
215
+ DECLARATION_REQUIRED_SOURCES.include?(dependency.source.to_s)
216
+ end
217
+
218
+ def component_for_dependency(definition, dependency)
219
+ if definition.absent_dependency?(dependency)
220
+ raise UnidentifiedSubscriber, "captured identity :#{definition.name} from #{definition.source_label} is absent"
221
+ end
222
+
223
+ identity_component(definition.name, dependency.key.fetch(:value))
224
+ end
225
+
226
+ def subscribe_components(connection, definitions)
227
+ definitions.filter_map do |definition|
228
+ value = call_subscribe_block(definition, connection)
229
+ if definition.absent?(value)
230
+ raise UnidentifiedSubscriber, "subscribe identity :#{definition.name} from #{definition.source_label} is absent"
231
+ end
232
+
233
+ identity_component(definition.name, subscribe_identity_value(definition, value))
234
+ end
235
+ end
236
+
237
+ def call_subscribe_block(definition, connection)
238
+ block = definition.subscribe_block
239
+ context = SubscribeContext.new(connection)
240
+ block.arity == 1 ? block.call(context) : context.instance_exec(&block)
241
+ end
242
+
243
+ def subscribe_identity_value(definition, value)
244
+ case definition.source
245
+ when :session, :cookie
246
+ Dependencies.private_fingerprint(value)
247
+ else
248
+ canonical_identity_value(value)
249
+ end
250
+ end
251
+
252
+ def identity_component(name, value)
253
+ {
254
+ name: name.to_s,
255
+ kind: "identity",
256
+ value: normalize_component_value(value)
257
+ }
258
+ end
259
+
260
+ def canonical_identity_value(value)
261
+ case value
262
+ when nil, true, false, Numeric, String, Symbol
263
+ value
264
+ when Array
265
+ value.map { |item| canonical_identity_value(item) }
266
+ when Hash
267
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_identity_value(value.fetch(key))] }
268
+ else
269
+ model_identity = Dependencies.model_identity(value)
270
+ return model_identity if model_identity
271
+
272
+ return { global_id: value.to_gid_param } if value.respond_to?(:to_gid_param)
273
+
274
+ raise UnidentifiedSubscriber, "identity value #{value.class.name} has no canonical identity"
275
+ end
276
+ end
277
+
278
+ def normalize_component_value(value)
279
+ case value
280
+ when Hash
281
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, normalize_component_value(value.fetch(key))] }
282
+ when Array
283
+ value.map { |item| normalize_component_value(item) }
284
+ when Symbol
285
+ value.to_s
286
+ else
287
+ value
288
+ end
289
+ end
290
+
291
+ def model_component(name, identity)
292
+ return unless identity.is_a?(Hash) && identity[:model] && identity[:id]
293
+
294
+ {
295
+ name: name.to_s,
296
+ kind: "model",
297
+ model: identity.fetch(:model),
298
+ id: identity.fetch(:id).to_s
299
+ }
300
+ end
301
+
302
+ def component_for(name, value)
303
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} is nil" if value.nil?
304
+
305
+ if active_record?(value)
306
+ active_record_component(name, value)
307
+ elsif scalar?(value)
308
+ scalar_component(name, value)
309
+ elsif value.respond_to?(:to_gid_param)
310
+ global_id_component(name, value)
311
+ else
312
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} has no canonical identity"
313
+ end
314
+ end
315
+
316
+ def active_record?(value)
317
+ defined?(::ActiveRecord::Base) && value.is_a?(::ActiveRecord::Base)
318
+ end
319
+
320
+ def active_record_component(name, record)
321
+ raise UnidentifiedSubscriber, "ActionCable identifier #{name} is an unsaved record" unless record.id
322
+
323
+ model_component(name, model: record.class.name, id: record.id)
324
+ end
325
+
326
+ def scalar?(value)
327
+ value.is_a?(String) ||
328
+ value.is_a?(Symbol) ||
329
+ value.is_a?(Integer) ||
330
+ value == true ||
331
+ value == false
332
+ end
333
+
334
+ def scalar_component(name, value)
335
+ {
336
+ name: name.to_s,
337
+ kind: "scalar",
338
+ class: value.class.name,
339
+ value: value.to_s
340
+ }
341
+ end
342
+
343
+ def global_id_component(name, value)
344
+ {
345
+ name: name.to_s,
346
+ kind: "global_id",
347
+ value: value.to_gid_param
348
+ }
349
+ end
350
+
351
+ def metadata_value(subscription, key)
352
+ subscription.metadata[key] || subscription.metadata[key.to_s]
353
+ end
354
+
355
+ def configuration
356
+ Upkeep::Rails.configuration
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cable/subscriber_identity"
4
+ require_relative "cable/channel"
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Upkeep
6
+ module Rails
7
+ module ClientSubscription
8
+ CHANNEL = "Upkeep::Rails::Cable::Channel"
9
+ ID_HEADER = "X-Upkeep-Subscription-Id"
10
+ TOKEN_HEADER = "X-Upkeep-Subscription-Token"
11
+ CHANNEL_HEADER = "X-Upkeep-Subscription-Channel"
12
+ STREAM_HEADER = "X-Upkeep-Subscription-Stream"
13
+ ACTION_HEADER = "X-Upkeep-Subscription-Action"
14
+
15
+ module_function
16
+
17
+ def inject(html, identity:, subscription:)
18
+ marker = marker_for(identity: identity, subscription: subscription)
19
+ insert_before_closing("body", html, marker) ||
20
+ "#{html}#{marker}"
21
+ end
22
+
23
+ # The payload travels as attributes (like turbo-cable-stream-source), never
24
+ # as text content, so it can't show up as page text when JS is absent.
25
+ def marker_for(identity:, subscription:)
26
+ attributes = payload_for(identity: identity, subscription: subscription).merge(
27
+ "id" => "upkeep-subscription-source"
28
+ )
29
+
30
+ [
31
+ %(<upkeep-subscription-source ),
32
+ attributes.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" "),
33
+ %( hidden style="display:none" data-upkeep-subscription>),
34
+ %(</upkeep-subscription-source>)
35
+ ].join
36
+ end
37
+
38
+ def headers_for(identity:, subscription:)
39
+ payload = payload_for(identity: identity, subscription: subscription)
40
+ {
41
+ ID_HEADER => payload.fetch("subscription-id"),
42
+ TOKEN_HEADER => payload.fetch("activation-token"),
43
+ CHANNEL_HEADER => payload.fetch("channel"),
44
+ STREAM_HEADER => payload.fetch("stream-name")
45
+ }
46
+ end
47
+
48
+ def payload_for(identity:, subscription:)
49
+ {
50
+ "channel" => CHANNEL,
51
+ "subscription-id" => subscription.id,
52
+ "activation-token" => ActivationToken.generate(subscription),
53
+ "stream-name" => identity.stream_name
54
+ }
55
+ end
56
+
57
+ def insert_before_closing(tag, html, marker)
58
+ index = html.rindex(%(</#{tag}>)) || html.rindex(%(</#{tag.upcase}>))
59
+ return unless index
60
+
61
+ "#{html[0...index]}#{marker}#{html[index..]}"
62
+ end
63
+ end
64
+ end
65
+ end