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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +518 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +514 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +821 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +320 -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,143 @@
|
|
|
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
|
+
def subscribed
|
|
13
|
+
if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
|
|
14
|
+
instrumented_subscribe
|
|
15
|
+
else
|
|
16
|
+
subscribe_without_instrumentation
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def unsubscribed
|
|
21
|
+
Upkeep::Rails.subscriptions.unregister(subscription_id)
|
|
22
|
+
rescue KeyError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def instrumented_subscribe
|
|
29
|
+
payload = { subscription_id: safe_subscription_id }
|
|
30
|
+
ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
|
|
31
|
+
subscribe_without_instrumentation(payload: payload)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subscribe_without_instrumentation(payload: nil)
|
|
36
|
+
id = subscription_id
|
|
37
|
+
payload[:subscription_id] = id if payload
|
|
38
|
+
unless measure(payload, :activation_token_ms) { valid_activation_token?(id) }
|
|
39
|
+
return reject_upkeep_subscription(payload, "invalid_activation_token")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
subscription = measure(payload, :fetch_ms) { Upkeep::Rails.subscriptions.fetch(id) }
|
|
43
|
+
authorized = measure(payload, :authorization_ms) { authorized_subscription?(subscription) }
|
|
44
|
+
unless authorized
|
|
45
|
+
return reject_upkeep_subscription(payload, "unauthorized_identity")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
measure(payload, :activation_ms) { Upkeep::Rails.subscriptions.activate(id) }
|
|
49
|
+
stream_count = measure(payload, :stream_attach_ms) { attach_streams(subscription) }
|
|
50
|
+
payload[:stream_count] = stream_count if payload
|
|
51
|
+
rescue KeyError
|
|
52
|
+
reject_upkeep_subscription(payload, missing_activation_token? ? "missing_activation_token" : "missing_subscription")
|
|
53
|
+
rescue UnidentifiedSubscriber
|
|
54
|
+
reject_upkeep_subscription(payload, "unidentified_subscriber")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reject_upkeep_subscription(payload, reason)
|
|
58
|
+
payload[:rejected] = true if payload
|
|
59
|
+
payload[:reject_reason] = reason if payload
|
|
60
|
+
log_subscription_rejection(reason)
|
|
61
|
+
reject
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def attach_streams(subscription)
|
|
65
|
+
stream_from stream_name_for(subscription)
|
|
66
|
+
count = 1
|
|
67
|
+
shared_stream_names_for(subscription).each do |stream_name|
|
|
68
|
+
stream_from stream_name
|
|
69
|
+
count += 1
|
|
70
|
+
end
|
|
71
|
+
count
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def measure(payload, key)
|
|
75
|
+
return yield unless payload
|
|
76
|
+
|
|
77
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
yield
|
|
79
|
+
ensure
|
|
80
|
+
payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3) if payload && started_at
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def safe_subscription_id
|
|
84
|
+
subscription_id
|
|
85
|
+
rescue KeyError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def subscription_id
|
|
90
|
+
params.fetch(:subscription_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def activation_token
|
|
94
|
+
params.fetch(:activation_token)
|
|
95
|
+
rescue KeyError
|
|
96
|
+
@missing_activation_token = true
|
|
97
|
+
raise
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def missing_activation_token?
|
|
101
|
+
@missing_activation_token == true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def valid_activation_token?(id)
|
|
105
|
+
ActivationToken.valid_for_subscription?(activation_token, id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def log_subscription_rejection(reason)
|
|
109
|
+
return unless reason == "missing_activation_token"
|
|
110
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
111
|
+
|
|
112
|
+
::Rails.logger.warn(
|
|
113
|
+
"[upkeep] subscription rejected: missing activation_token. " \
|
|
114
|
+
"If this started after upgrading upkeep-rails, refresh app/javascript/upkeep/subscription.js from the install generator."
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def authorized_subscription?(subscription)
|
|
119
|
+
return true if anonymous_public_subscription?(subscription)
|
|
120
|
+
return true unless metadata_value(subscription, :identity_mode)
|
|
121
|
+
|
|
122
|
+
SubscriberIdentity.derive_for_subscription(connection, subscription).subscriber_id == subscription.subscriber_id
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def anonymous_public_subscription?(subscription)
|
|
126
|
+
metadata_value(subscription, :identity_mode) == SubscriberIdentity::ANONYMOUS_PUBLIC_MODE
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stream_name_for(subscription)
|
|
130
|
+
metadata_value(subscription, :stream_name) || subscription.metadata.fetch(:stream_name)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def shared_stream_names_for(subscription)
|
|
134
|
+
metadata_value(subscription, :shared_stream_names) || []
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def metadata_value(subscription, key)
|
|
138
|
+
subscription.metadata[key] || subscription.metadata[key.to_s]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,341 @@
|
|
|
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
|
+
def action_cable_request
|
|
45
|
+
action_cable_connection.__send__(:request)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module SubscriberIdentity
|
|
50
|
+
ANONYMOUS_PUBLIC_MODE = "anonymous_public"
|
|
51
|
+
IDENTIFIED_MODE = "identified"
|
|
52
|
+
DECLARATION_REQUIRED_SOURCES = %w[Current.user current_attribute warden_user].freeze
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
def derive(connection)
|
|
57
|
+
derive_all(connection).last
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def derive_all(connection)
|
|
61
|
+
components = subscribe_components(connection, configuration.identity_definitions)
|
|
62
|
+
raise UnidentifiedSubscriber, "ActionCable connection has no declared Upkeep identities" if components.empty?
|
|
63
|
+
|
|
64
|
+
[for_components(components)]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def derive_for_subscription(connection, subscription)
|
|
68
|
+
names = metadata_value(subscription, :identity_names) || []
|
|
69
|
+
definitions = names.map { |name| configuration.identity_definition(name) }
|
|
70
|
+
components = subscribe_components(connection, definitions)
|
|
71
|
+
raise UnidentifiedSubscriber, "ActionCable connection did not resolve subscription identities #{names.inspect}" if components.empty?
|
|
72
|
+
|
|
73
|
+
for_components(components)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def derive_from_request(request, recorder:, decision: decision_for(request, recorder: recorder))
|
|
77
|
+
components = if decision.anonymous
|
|
78
|
+
anonymous_components
|
|
79
|
+
else
|
|
80
|
+
recorder_components(recorder)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if components.empty?
|
|
84
|
+
raise UnidentifiedSubscriber,
|
|
85
|
+
"subscription has identity dependencies but no declared Upkeep identity mapping"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
for_components(components)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decision_for(_request = nil, recorder:)
|
|
92
|
+
configured_dependencies = configured_identity_dependencies(recorder)
|
|
93
|
+
undeclared_dependencies = undeclared_identity_dependencies(recorder)
|
|
94
|
+
|
|
95
|
+
if configured_dependencies.any?
|
|
96
|
+
Decision.new(
|
|
97
|
+
IDENTIFIED_MODE,
|
|
98
|
+
false,
|
|
99
|
+
"identity_dependencies_present",
|
|
100
|
+
configured_dependencies.map { |definition, _dependency| definition.source.to_s }.uniq.sort,
|
|
101
|
+
configured_dependencies.map { |definition, _dependency| definition.name.to_s }.uniq.sort
|
|
102
|
+
)
|
|
103
|
+
elsif undeclared_dependencies.any?
|
|
104
|
+
Decision.new(
|
|
105
|
+
IDENTIFIED_MODE,
|
|
106
|
+
false,
|
|
107
|
+
"identity_setup_required",
|
|
108
|
+
undeclared_dependencies.map { |dependency| dependency.source.to_s }.uniq.sort,
|
|
109
|
+
[]
|
|
110
|
+
)
|
|
111
|
+
else
|
|
112
|
+
Decision.new(
|
|
113
|
+
ANONYMOUS_PUBLIC_MODE,
|
|
114
|
+
true,
|
|
115
|
+
nil,
|
|
116
|
+
[],
|
|
117
|
+
[]
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def identifier_components(connection)
|
|
123
|
+
identifiers = Array(connection.identifiers)
|
|
124
|
+
identifiers.map { |name| component_for(name, connection.public_send(name)) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def for_identifiers(identifiers)
|
|
128
|
+
for_components(identifiers.map { |name, value| component_for(name, value) })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def for_components(components)
|
|
132
|
+
canonical_bytes = JSON.generate(components.sort_by { |component| component.fetch(:name) })
|
|
133
|
+
subscriber_id = "action_cable:#{Digest::SHA256.hexdigest(canonical_bytes)}"
|
|
134
|
+
|
|
135
|
+
Identity.new(
|
|
136
|
+
subscriber_id,
|
|
137
|
+
Delivery::ActionCableAdapter.stream_name_for(subscriber_id),
|
|
138
|
+
components
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def recorder_components(recorder)
|
|
143
|
+
components_by_name = Hash.new { |hash, key| hash[key] = [] }
|
|
144
|
+
configured_identity_dependencies(recorder).each do |definition, dependency|
|
|
145
|
+
component = component_for_dependency(definition, dependency)
|
|
146
|
+
components_by_name[definition.name] << component if component
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
components_by_name.map do |name, components|
|
|
150
|
+
unique_components = components.uniq
|
|
151
|
+
if unique_components.size > 1
|
|
152
|
+
raise UnidentifiedSubscriber, "captured identity :#{name} changed during request"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
unique_components.first
|
|
156
|
+
end.compact
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def anonymous_components
|
|
160
|
+
[ scalar_component(:anonymous_public_subscription, SecureRandom.uuid) ]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def identity_dependencies(recorder)
|
|
164
|
+
return [] unless recorder
|
|
165
|
+
|
|
166
|
+
recorder.graph.dependency_nodes
|
|
167
|
+
.map(&:payload)
|
|
168
|
+
.select { |dependency| Dependencies.partitioning_identity?(dependency) }
|
|
169
|
+
.uniq(&:cache_key)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def configured_identity_dependencies(recorder)
|
|
173
|
+
definitions = configuration.identity_definitions
|
|
174
|
+
return [] if definitions.empty?
|
|
175
|
+
|
|
176
|
+
identity_dependencies(recorder).flat_map do |dependency|
|
|
177
|
+
definitions.select { |definition| definition.matches_dependency?(dependency) }
|
|
178
|
+
.reject { |definition| definition.absent_dependency?(dependency) }
|
|
179
|
+
.map { |definition| [definition, dependency] }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def undeclared_identity_dependencies(recorder)
|
|
184
|
+
identity_dependencies(recorder).select do |dependency|
|
|
185
|
+
declaration_required_dependency?(dependency) &&
|
|
186
|
+
configured_identity_dependencies_for_dependency(dependency).empty?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def configured_identity_dependencies_for_dependency(dependency)
|
|
191
|
+
configuration.identity_definitions.select { |definition| definition.matches_dependency?(dependency) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def declaration_required_dependency?(dependency)
|
|
195
|
+
DECLARATION_REQUIRED_SOURCES.include?(dependency.source.to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def component_for_dependency(definition, dependency)
|
|
199
|
+
if definition.absent_dependency?(dependency)
|
|
200
|
+
raise UnidentifiedSubscriber, "captured identity :#{definition.name} from #{definition.source_label} is absent"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
identity_component(definition.name, dependency.key.fetch(:value))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def subscribe_components(connection, definitions)
|
|
207
|
+
definitions.filter_map do |definition|
|
|
208
|
+
value = call_subscribe_block(definition, connection)
|
|
209
|
+
if definition.absent?(value)
|
|
210
|
+
raise UnidentifiedSubscriber, "subscribe identity :#{definition.name} from #{definition.source_label} is absent"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
identity_component(definition.name, subscribe_identity_value(definition, value))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def call_subscribe_block(definition, connection)
|
|
218
|
+
block = definition.subscribe_block
|
|
219
|
+
context = SubscribeContext.new(connection)
|
|
220
|
+
block.arity == 1 ? block.call(context) : context.instance_exec(&block)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def subscribe_identity_value(definition, value)
|
|
224
|
+
case definition.source
|
|
225
|
+
when :session, :cookie
|
|
226
|
+
Dependencies.private_fingerprint(value)
|
|
227
|
+
else
|
|
228
|
+
canonical_identity_value(value)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def identity_component(name, value)
|
|
233
|
+
{
|
|
234
|
+
name: name.to_s,
|
|
235
|
+
kind: "identity",
|
|
236
|
+
value: normalize_component_value(value)
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def canonical_identity_value(value)
|
|
241
|
+
case value
|
|
242
|
+
when nil, true, false, Numeric, String, Symbol
|
|
243
|
+
value
|
|
244
|
+
when Array
|
|
245
|
+
value.map { |item| canonical_identity_value(item) }
|
|
246
|
+
when Hash
|
|
247
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_identity_value(value.fetch(key))] }
|
|
248
|
+
else
|
|
249
|
+
model_identity = Dependencies.model_identity(value)
|
|
250
|
+
return model_identity if model_identity
|
|
251
|
+
|
|
252
|
+
return { global_id: value.to_gid_param } if value.respond_to?(:to_gid_param)
|
|
253
|
+
|
|
254
|
+
raise UnidentifiedSubscriber, "identity value #{value.class.name} has no canonical identity"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def normalize_component_value(value)
|
|
259
|
+
case value
|
|
260
|
+
when Hash
|
|
261
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, normalize_component_value(value.fetch(key))] }
|
|
262
|
+
when Array
|
|
263
|
+
value.map { |item| normalize_component_value(item) }
|
|
264
|
+
when Symbol
|
|
265
|
+
value.to_s
|
|
266
|
+
else
|
|
267
|
+
value
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def model_component(name, identity)
|
|
272
|
+
return unless identity.is_a?(Hash) && identity[:model] && identity[:id]
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
name: name.to_s,
|
|
276
|
+
kind: "model",
|
|
277
|
+
model: identity.fetch(:model),
|
|
278
|
+
id: identity.fetch(:id).to_s
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def component_for(name, value)
|
|
283
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} is nil" if value.nil?
|
|
284
|
+
|
|
285
|
+
if active_record?(value)
|
|
286
|
+
active_record_component(name, value)
|
|
287
|
+
elsif scalar?(value)
|
|
288
|
+
scalar_component(name, value)
|
|
289
|
+
elsif value.respond_to?(:to_gid_param)
|
|
290
|
+
global_id_component(name, value)
|
|
291
|
+
else
|
|
292
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} has no canonical identity"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def active_record?(value)
|
|
297
|
+
defined?(::ActiveRecord::Base) && value.is_a?(::ActiveRecord::Base)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def active_record_component(name, record)
|
|
301
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} is an unsaved record" unless record.id
|
|
302
|
+
|
|
303
|
+
model_component(name, model: record.class.name, id: record.id)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def scalar?(value)
|
|
307
|
+
value.is_a?(String) ||
|
|
308
|
+
value.is_a?(Symbol) ||
|
|
309
|
+
value.is_a?(Integer) ||
|
|
310
|
+
value == true ||
|
|
311
|
+
value == false
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def scalar_component(name, value)
|
|
315
|
+
{
|
|
316
|
+
name: name.to_s,
|
|
317
|
+
kind: "scalar",
|
|
318
|
+
class: value.class.name,
|
|
319
|
+
value: value.to_s
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def global_id_component(name, value)
|
|
324
|
+
{
|
|
325
|
+
name: name.to_s,
|
|
326
|
+
kind: "global_id",
|
|
327
|
+
value: value.to_gid_param
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def metadata_value(subscription, key)
|
|
332
|
+
subscription.metadata[key] || subscription.metadata[key.to_s]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def configuration
|
|
336
|
+
Upkeep::Rails.configuration
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module Rails
|
|
8
|
+
module ClientSubscription
|
|
9
|
+
CHANNEL = "Upkeep::Rails::Cable::Channel"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def inject(html, identity:, subscription:)
|
|
14
|
+
marker = marker_for(identity: identity, subscription: subscription)
|
|
15
|
+
insert_before_closing("body", html, marker) ||
|
|
16
|
+
"#{html}#{marker}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def marker_for(identity:, subscription:)
|
|
20
|
+
payload = JSON.generate(
|
|
21
|
+
channel: CHANNEL,
|
|
22
|
+
subscription_id: subscription.id,
|
|
23
|
+
activation_token: ActivationToken.generate(subscription),
|
|
24
|
+
stream_name: identity.stream_name
|
|
25
|
+
).gsub("</", '<\/')
|
|
26
|
+
|
|
27
|
+
id = "upkeep-subscription-source-#{subscription.id}"
|
|
28
|
+
|
|
29
|
+
[
|
|
30
|
+
%(<upkeep-subscription-source id="#{CGI.escapeHTML(id)}" ),
|
|
31
|
+
%(data-upkeep-subscription data-turbo-temporary>),
|
|
32
|
+
CGI.escapeHTML(payload),
|
|
33
|
+
%(</upkeep-subscription-source>)
|
|
34
|
+
].join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def insert_before_closing(tag, html, marker)
|
|
38
|
+
index = html.rindex(%(</#{tag}>)) || html.rindex(%(</#{tag.upcase}>))
|
|
39
|
+
return unless index
|
|
40
|
+
|
|
41
|
+
"#{html[0...index]}#{marker}#{html[index..]}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|