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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +244 -0
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/how-it-works.md +329 -0
- data/docs/plans/sqlglot-active-record-migration.md +211 -0
- data/docs/plans/turbo-frame-subscription-composition.md +90 -0
- data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
- data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
- data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
- data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
- data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
- data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
- data/lib/generators/upkeep/install/install_generator.rb +192 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +288 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
- data/lib/upkeep/active_record_query.rb +248 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +454 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +48 -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 +339 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +600 -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 +548 -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 +411 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +1007 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +165 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +65 -0
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +252 -0
- data/lib/upkeep/rails/controller_runtime.rb +187 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/job_runtime.rb +43 -0
- data/lib/upkeep/rails/railtie.rb +44 -0
- data/lib/upkeep/rails/replay.rb +244 -0
- data/lib/upkeep/rails/testing.rb +259 -0
- data/lib/upkeep/rails.rb +466 -0
- data/lib/upkeep/replay.rb +462 -0
- data/lib/upkeep/runtime.rb +1276 -0
- data/lib/upkeep/shared_streams.rb +86 -0
- data/lib/upkeep/sql_dependency_analysis.rb +553 -0
- data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
- data/lib/upkeep/sqlglot/native.rb +121 -0
- data/lib/upkeep/sqlglot/native_library.rb +23 -0
- data/lib/upkeep/sqlglot.rb +367 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
- data/lib/upkeep/subscriptions/active_registry.rb +80 -0
- data/lib/upkeep/subscriptions/base_store.rb +110 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
- data/lib/upkeep/subscriptions/shape.rb +117 -0
- data/lib/upkeep/subscriptions/store.rb +349 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +146 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +15 -0
- data/upkeep-rails.gemspec +66 -0
- metadata +327 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Upkeep
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
SOLID_CABLE_DEVELOPMENT_BLOCK = <<~YAML
|
|
14
|
+
development:
|
|
15
|
+
adapter: solid_cable
|
|
16
|
+
connects_to:
|
|
17
|
+
database:
|
|
18
|
+
writing: cable
|
|
19
|
+
polling_interval: 0.1.seconds
|
|
20
|
+
message_retention: 1.day
|
|
21
|
+
YAML
|
|
22
|
+
|
|
23
|
+
def self.next_migration_number(dirname)
|
|
24
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_subscription_migration
|
|
28
|
+
return if migration_exists?("create_upkeep_subscriptions")
|
|
29
|
+
|
|
30
|
+
@migration_version = ActiveRecord::Migration.current_version
|
|
31
|
+
migration_template "create_upkeep_subscriptions.rb.erb", "db/migrate/create_upkeep_subscriptions.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_initializer
|
|
35
|
+
template "upkeep.rb", "config/initializers/upkeep.rb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_browser_bootstrap
|
|
39
|
+
template "subscription.js", "app/javascript/upkeep/subscription.js"
|
|
40
|
+
append_application_import
|
|
41
|
+
pin_action_cable
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mount_action_cable
|
|
45
|
+
return if routes_path.exist? && routes_path.read.include?("ActionCable.server")
|
|
46
|
+
|
|
47
|
+
route %(mount ActionCable.server => "/cable")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def configure_development_cable_adapter
|
|
51
|
+
unless solid_cable_in_gemfile?
|
|
52
|
+
show_solid_cable_guidance
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
return unless cable_config_path.exist?
|
|
56
|
+
|
|
57
|
+
content = cable_config_path.read
|
|
58
|
+
block = development_cable_block(content)
|
|
59
|
+
return if block&.include?("solid_cable")
|
|
60
|
+
|
|
61
|
+
updated = if block
|
|
62
|
+
content.sub(block, "#{SOLID_CABLE_DEVELOPMENT_BLOCK}\n")
|
|
63
|
+
else
|
|
64
|
+
"#{SOLID_CABLE_DEVELOPMENT_BLOCK}\n#{content}"
|
|
65
|
+
end
|
|
66
|
+
File.write(cable_config_path, updated)
|
|
67
|
+
say "Updated config/cable.yml development to the solid_cable adapter."
|
|
68
|
+
say "Ensure config/database.yml defines a cable database for development and loads db/cable_schema.rb."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def show_identity_setup_guidance
|
|
72
|
+
usages = detected_identity_usages
|
|
73
|
+
return if usages.empty?
|
|
74
|
+
|
|
75
|
+
say "\nIdentity setup required", :yellow
|
|
76
|
+
say "Upkeep found request-side identity usage:"
|
|
77
|
+
usages.each { |usage| say " #{usage}" }
|
|
78
|
+
say "Upkeep does not infer subscriber identity by naming convention."
|
|
79
|
+
say "Add an explicit Upkeep::Rails.configure identity mapping in config/initializers/upkeep.rb."
|
|
80
|
+
say "Pages that depend on undeclared non-absent CurrentAttributes or Warden identities are refused for live updates."
|
|
81
|
+
say ""
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def solid_cable_in_gemfile?
|
|
87
|
+
gemfile_path.exist? && gemfile_path.read.match?(/^\s*gem\s+["']solid_cable["']/)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def development_cable_block(content)
|
|
91
|
+
content[/^development:\n(?:(?:[ \t].*)?\n)*/]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def show_solid_cable_guidance
|
|
95
|
+
say "\nAction Cable adapter", :yellow
|
|
96
|
+
say "Upkeep delivers live updates through standard Action Cable broadcasts."
|
|
97
|
+
say "The async development cable adapter is in-process only; with a clustered server"
|
|
98
|
+
say "(puma workers > 0), broadcasts from one process never reach browsers connected to another."
|
|
99
|
+
say "Add solid_cable and point config/cable.yml development at it:"
|
|
100
|
+
say " bundle add solid_cable"
|
|
101
|
+
say " bin/rails solid_cable:install"
|
|
102
|
+
say " bin/rails db:prepare"
|
|
103
|
+
say ""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def migration_exists?(name)
|
|
107
|
+
Dir.glob(destination_path("db/migrate/*.rb")).any? do |path|
|
|
108
|
+
File.basename(path).include?(name)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def append_application_import
|
|
113
|
+
return unless application_js_path.exist?
|
|
114
|
+
|
|
115
|
+
upkeep_specifier = importmap_path.exist? ? "upkeep/subscription" : "./upkeep/subscription"
|
|
116
|
+
content = application_js_path.read
|
|
117
|
+
.gsub(/^import ["'](?:\.\/)?upkeep\/subscription["']\s*\n?/, "")
|
|
118
|
+
|
|
119
|
+
turbo_import = /^import ["']@hotwired\/turbo-rails["']\s*$/
|
|
120
|
+
if (match = content.match(turbo_import))
|
|
121
|
+
content.insert(match.begin(0), %(import "#{upkeep_specifier}"\n))
|
|
122
|
+
else
|
|
123
|
+
content << "\n" unless content.empty? || content.end_with?("\n")
|
|
124
|
+
content << %(import "#{upkeep_specifier}"\n)
|
|
125
|
+
content << %(import "@hotwired/turbo-rails"\n)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
File.write(application_js_path, content)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pin_action_cable
|
|
132
|
+
return unless importmap_path.exist?
|
|
133
|
+
|
|
134
|
+
pin_importmap("@hotwired/turbo-rails", "turbo.min.js")
|
|
135
|
+
pin_importmap("@rails/actioncable", "actioncable.esm.js")
|
|
136
|
+
pin_importmap("upkeep/subscription", "upkeep/subscription.js")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pin_importmap(specifier, asset)
|
|
140
|
+
return if importmap_path.read.include?(%("#{specifier}"))
|
|
141
|
+
|
|
142
|
+
append_to_file importmap_path.to_s, %(pin "#{specifier}", to: "#{asset}"\n)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def detected_identity_usages
|
|
146
|
+
usages = []
|
|
147
|
+
source = application_source
|
|
148
|
+
usages << "Current.user" if source.match?(/\bCurrent\.user\b/)
|
|
149
|
+
usages << "session[:user_id]" if source.match?(/\bsession\[(?::user_id|['"]user_id['"])\]/)
|
|
150
|
+
usages << "warden.user" if source.match?(/\bwarden\.user\b/)
|
|
151
|
+
usages.uniq
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def application_source
|
|
155
|
+
app_paths.filter_map do |path|
|
|
156
|
+
File.read(path)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def app_paths
|
|
163
|
+
Dir.glob(destination_path("app/**/*")).select do |path|
|
|
164
|
+
File.file?(path) && %w[.rb .erb].include?(File.extname(path))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def routes_path
|
|
169
|
+
Pathname(destination_path("config/routes.rb"))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def application_js_path
|
|
173
|
+
Pathname(destination_path("app/javascript/application.js"))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def importmap_path
|
|
177
|
+
Pathname(destination_path("config/importmap.rb"))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def gemfile_path
|
|
181
|
+
Pathname(destination_path("Gemfile"))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def cable_config_path
|
|
185
|
+
Pathname(destination_path("config/cable.yml"))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def destination_path(path)
|
|
189
|
+
File.join(destination_root, path)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class CreateUpkeepSubscriptions < ActiveRecord::Migration[<%= @migration_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :upkeep_subscriptions, id: :string do |t|
|
|
4
|
+
t.string :subscriber_id, null: false
|
|
5
|
+
t.json :recorder_snapshot, null: false
|
|
6
|
+
t.json :metadata
|
|
7
|
+
t.string :subscription_shape_key
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
add_index :upkeep_subscriptions, :subscriber_id
|
|
11
|
+
add_index :upkeep_subscriptions, :subscription_shape_key, name: "idx_upkeep_subscriptions_on_shape_key"
|
|
12
|
+
|
|
13
|
+
create_table :upkeep_subscription_index_entries do |t|
|
|
14
|
+
t.string :subscription_id, null: false
|
|
15
|
+
t.string :lookup_key_digest, null: false
|
|
16
|
+
t.string :dependency_source, null: false
|
|
17
|
+
t.string :lookup_table, null: false
|
|
18
|
+
t.json :lookup_record_id_snapshot
|
|
19
|
+
t.string :lookup_attribute, null: false
|
|
20
|
+
t.string :dependency_table, null: false
|
|
21
|
+
t.string :dependency_predicate_digest
|
|
22
|
+
t.json :dependency_metadata_snapshot
|
|
23
|
+
t.json :owner_ids_snapshot, null: false
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
add_index :upkeep_subscription_index_entries, :subscription_id
|
|
27
|
+
add_index :upkeep_subscription_index_entries, :lookup_key_digest
|
|
28
|
+
add_foreign_key :upkeep_subscription_index_entries,
|
|
29
|
+
:upkeep_subscriptions,
|
|
30
|
+
column: :subscription_id,
|
|
31
|
+
on_delete: :cascade
|
|
32
|
+
|
|
33
|
+
create_table :upkeep_subscription_shape_index_entries do |t|
|
|
34
|
+
t.string :subscription_shape_key, null: false
|
|
35
|
+
t.string :lookup_key_digest, null: false
|
|
36
|
+
t.string :dependency_source, null: false
|
|
37
|
+
t.string :lookup_table, null: false
|
|
38
|
+
t.json :lookup_record_id_snapshot
|
|
39
|
+
t.string :lookup_attribute, null: false
|
|
40
|
+
t.string :dependency_table, null: false
|
|
41
|
+
t.string :dependency_predicate_digest
|
|
42
|
+
t.json :dependency_metadata_snapshot
|
|
43
|
+
t.json :owner_ids_snapshot, null: false
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
add_index :upkeep_subscription_shape_index_entries, :subscription_shape_key, name: "idx_upkeep_sub_shape_entries_on_shape_key"
|
|
47
|
+
add_index :upkeep_subscription_shape_index_entries, :lookup_key_digest, name: "idx_upkeep_sub_shape_entries_on_lookup_digest"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createConsumer } from "@rails/actioncable"
|
|
2
|
+
|
|
3
|
+
const SOURCE_ELEMENT = "upkeep-subscription-source"
|
|
4
|
+
const SOURCE_SELECTOR = `${SOURCE_ELEMENT}[data-upkeep-subscription]`
|
|
5
|
+
const ID_HEADER = "X-Upkeep-Subscription-Id"
|
|
6
|
+
const TOKEN_HEADER = "X-Upkeep-Subscription-Token"
|
|
7
|
+
const CHANNEL_HEADER = "X-Upkeep-Subscription-Channel"
|
|
8
|
+
const STREAM_HEADER = "X-Upkeep-Subscription-Stream"
|
|
9
|
+
const ACTION_HEADER = "X-Upkeep-Subscription-Action"
|
|
10
|
+
|
|
11
|
+
let consumer
|
|
12
|
+
let activeFrame
|
|
13
|
+
const queuedFrames = []
|
|
14
|
+
|
|
15
|
+
function cableConsumer() {
|
|
16
|
+
consumer ||= createConsumer()
|
|
17
|
+
return consumer
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sourceElement() {
|
|
21
|
+
return document.querySelector(SOURCE_SELECTOR)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureSourceElement() {
|
|
25
|
+
const existing = sourceElement()
|
|
26
|
+
if (existing) return existing
|
|
27
|
+
if (!document.body) return
|
|
28
|
+
|
|
29
|
+
const source = document.createElement(SOURCE_ELEMENT)
|
|
30
|
+
source.id = "upkeep-subscription-source"
|
|
31
|
+
source.hidden = true
|
|
32
|
+
source.style.display = "none"
|
|
33
|
+
source.setAttribute("data-upkeep-subscription", "")
|
|
34
|
+
document.body.append(source)
|
|
35
|
+
return source
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parsePayload(element) {
|
|
39
|
+
return {
|
|
40
|
+
channel: element.getAttribute("channel"),
|
|
41
|
+
subscription_id: element.getAttribute("subscription-id"),
|
|
42
|
+
activation_token: element.getAttribute("activation-token"),
|
|
43
|
+
stream_name: element.getAttribute("stream-name")
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function responsePayload(fetchResponse) {
|
|
48
|
+
const headers = fetchResponse?.response?.headers
|
|
49
|
+
const subscriptionId = headers?.get(ID_HEADER)
|
|
50
|
+
if (!subscriptionId) return
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
channel: headers.get(CHANNEL_HEADER),
|
|
54
|
+
subscription_id: subscriptionId,
|
|
55
|
+
activation_token: headers.get(TOKEN_HEADER),
|
|
56
|
+
stream_name: headers.get(STREAM_HEADER)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setHeader(headers, name, value) {
|
|
61
|
+
if (headers?.set) {
|
|
62
|
+
headers.set(name, value)
|
|
63
|
+
} else {
|
|
64
|
+
headers[name] = value
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addSubscriptionHeaders(fetchOptions) {
|
|
69
|
+
const source = sourceElement()
|
|
70
|
+
if (!source) return
|
|
71
|
+
|
|
72
|
+
const payload = parsePayload(source)
|
|
73
|
+
if (!payload.subscription_id || !payload.activation_token) return
|
|
74
|
+
|
|
75
|
+
setHeader(fetchOptions.headers, ID_HEADER, payload.subscription_id)
|
|
76
|
+
setHeader(fetchOptions.headers, TOKEN_HEADER, payload.activation_token)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function beginFrameFetch(event) {
|
|
80
|
+
const frame = event.target
|
|
81
|
+
if (frame?.tagName !== "TURBO-FRAME") return
|
|
82
|
+
|
|
83
|
+
const start = () => {
|
|
84
|
+
activeFrame = frame
|
|
85
|
+
addSubscriptionHeaders(event.detail.fetchOptions)
|
|
86
|
+
event.detail.resume?.()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (activeFrame && activeFrame !== frame) {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
queuedFrames.push(start)
|
|
92
|
+
} else {
|
|
93
|
+
activeFrame = frame
|
|
94
|
+
addSubscriptionHeaders(event.detail.fetchOptions)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function finishFrameFetch(frame) {
|
|
99
|
+
if (activeFrame !== frame) return
|
|
100
|
+
|
|
101
|
+
activeFrame = null
|
|
102
|
+
queuedFrames.shift()?.()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resetFrameFetches() {
|
|
106
|
+
activeFrame = null
|
|
107
|
+
queuedFrames.splice(0)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function reloadPage() {
|
|
111
|
+
window.Turbo?.visit(window.location.href, { action: "replace", shouldCacheSnapshot: false })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderStreamMessage(data) {
|
|
115
|
+
if (window.Turbo?.renderStreamMessage) {
|
|
116
|
+
window.Turbo.renderStreamMessage(String(data))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assertTurboCompatibility() {
|
|
121
|
+
const turbo = window.Turbo
|
|
122
|
+
if (!turbo || turbo.StreamActions?.refresh) return
|
|
123
|
+
|
|
124
|
+
throw new Error("Upkeep requires Turbo 8 or newer")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class UpkeepSubscriptionSourceElement extends HTMLElement {
|
|
128
|
+
connectedCallback() {
|
|
129
|
+
this.connectStreamSource()
|
|
130
|
+
this.subscribe()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
disconnectedCallback() {
|
|
134
|
+
this.transitionGeneration = (this.transitionGeneration || 0) + 1
|
|
135
|
+
this.candidateSubscription?.unsubscribe()
|
|
136
|
+
this.candidateSubscription = null
|
|
137
|
+
this.unsubscribe()
|
|
138
|
+
this.disconnectStreamSource()
|
|
139
|
+
this.removeAttribute("connected")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
connectStreamSource() {
|
|
143
|
+
if (this.streamSourceConnected) return
|
|
144
|
+
if (!window.Turbo?.session?.connectStreamSource) return
|
|
145
|
+
|
|
146
|
+
window.Turbo.session.connectStreamSource(this)
|
|
147
|
+
this.streamSourceConnected = true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
disconnectStreamSource() {
|
|
151
|
+
if (!this.streamSourceConnected) return
|
|
152
|
+
|
|
153
|
+
window.Turbo.session.disconnectStreamSource(this)
|
|
154
|
+
this.streamSourceConnected = false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
subscribe() {
|
|
158
|
+
const payload = parsePayload(this)
|
|
159
|
+
if (!payload.subscription_id || this.subscription) return
|
|
160
|
+
|
|
161
|
+
this.subscription = this.createSubscription(payload, {
|
|
162
|
+
connected: () => this.setAttribute("connected", ""),
|
|
163
|
+
disconnected: () => this.removeAttribute("connected")
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
replaceSubscription(payload, { connected, rejected } = {}) {
|
|
168
|
+
if (!payload?.subscription_id) return
|
|
169
|
+
if (payload.subscription_id === parsePayload(this).subscription_id) {
|
|
170
|
+
connected?.()
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const generation = (this.transitionGeneration || 0) + 1
|
|
175
|
+
this.transitionGeneration = generation
|
|
176
|
+
this.candidateSubscription?.unsubscribe()
|
|
177
|
+
|
|
178
|
+
let candidate
|
|
179
|
+
candidate = this.createSubscription(payload, {
|
|
180
|
+
connected: () => {
|
|
181
|
+
if (this.transitionGeneration !== generation || !this.isConnected) {
|
|
182
|
+
candidate.unsubscribe()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const previous = this.subscription
|
|
187
|
+
this.subscription = candidate
|
|
188
|
+
this.candidateSubscription = null
|
|
189
|
+
this.applyPayload(payload)
|
|
190
|
+
this.setAttribute("connected", "")
|
|
191
|
+
previous?.unsubscribe()
|
|
192
|
+
connected?.()
|
|
193
|
+
},
|
|
194
|
+
rejected: () => {
|
|
195
|
+
if (this.transitionGeneration !== generation) return
|
|
196
|
+
|
|
197
|
+
this.candidateSubscription = null
|
|
198
|
+
rejected?.()
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
this.candidateSubscription = candidate
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
createSubscription(payload, callbacks = {}) {
|
|
205
|
+
const channel = payload.channel || "Upkeep::Rails::Cable::Channel"
|
|
206
|
+
|
|
207
|
+
return cableConsumer().subscriptions.create(
|
|
208
|
+
{
|
|
209
|
+
channel,
|
|
210
|
+
subscription_id: payload.subscription_id,
|
|
211
|
+
activation_token: payload.activation_token
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
connected: callbacks.connected,
|
|
215
|
+
disconnected: callbacks.disconnected,
|
|
216
|
+
received: (data) => this.receive(data),
|
|
217
|
+
|
|
218
|
+
rejected: () => {
|
|
219
|
+
console.error(
|
|
220
|
+
"[upkeep] subscription rejected by the server; the rejection reason is in the server log",
|
|
221
|
+
{ subscription_id: payload.subscription_id, channel }
|
|
222
|
+
)
|
|
223
|
+
callbacks.rejected?.()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
applyPayload(payload) {
|
|
230
|
+
this.setAttribute("channel", payload.channel || "Upkeep::Rails::Cable::Channel")
|
|
231
|
+
this.setAttribute("subscription-id", payload.subscription_id)
|
|
232
|
+
this.setAttribute("activation-token", payload.activation_token)
|
|
233
|
+
this.setAttribute("stream-name", payload.stream_name || "")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
unsubscribe() {
|
|
237
|
+
if (!this.subscription) return
|
|
238
|
+
|
|
239
|
+
this.subscription.unsubscribe()
|
|
240
|
+
this.subscription = null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
receive(data) {
|
|
244
|
+
if (this.streamSourceConnected) {
|
|
245
|
+
this.dispatchEvent(new MessageEvent("message", { data: String(data) }))
|
|
246
|
+
} else {
|
|
247
|
+
renderStreamMessage(data)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!customElements.get(SOURCE_ELEMENT)) {
|
|
253
|
+
customElements.define(SOURCE_ELEMENT, UpkeepSubscriptionSourceElement)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
ensureSourceElement()
|
|
257
|
+
connectUpkeepSubscriptions()
|
|
258
|
+
document.addEventListener("turbo:before-fetch-request", beginFrameFetch)
|
|
259
|
+
document.addEventListener("turbo:frame-render", (event) => {
|
|
260
|
+
const source = ensureSourceElement()
|
|
261
|
+
const fetchResponse = event.detail.fetchResponse
|
|
262
|
+
const action = fetchResponse?.response?.headers?.get(ACTION_HEADER)
|
|
263
|
+
const payload = responsePayload(fetchResponse)
|
|
264
|
+
|
|
265
|
+
if (action === "reload") {
|
|
266
|
+
reloadPage()
|
|
267
|
+
} else if (source && payload) {
|
|
268
|
+
source.replaceSubscription(payload, {
|
|
269
|
+
connected: () => finishFrameFetch(event.target),
|
|
270
|
+
rejected: () => reloadPage()
|
|
271
|
+
})
|
|
272
|
+
} else {
|
|
273
|
+
finishFrameFetch(event.target)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
document.addEventListener("turbo:fetch-request-error", (event) => finishFrameFetch(event.target))
|
|
277
|
+
document.addEventListener("turbo:frame-missing", (event) => finishFrameFetch(event.target))
|
|
278
|
+
document.addEventListener("turbo:before-visit", resetFrameFetches)
|
|
279
|
+
document.addEventListener("turbo:load", connectUpkeepSubscriptions)
|
|
280
|
+
|
|
281
|
+
export function connectUpkeepSubscriptions() {
|
|
282
|
+
assertTurboCompatibility()
|
|
283
|
+
ensureSourceElement()
|
|
284
|
+
document.querySelectorAll(SOURCE_SELECTOR).forEach((source) => {
|
|
285
|
+
source.connectStreamSource?.()
|
|
286
|
+
source.subscribe?.()
|
|
287
|
+
})
|
|
288
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Upkeep::Rails.configure do |config|
|
|
4
|
+
app_config = Rails.application.config.upkeep
|
|
5
|
+
|
|
6
|
+
config.enabled = app_config.fetch(:enabled, true)
|
|
7
|
+
config.request_activation = app_config.fetch(:request_activation, :all)
|
|
8
|
+
config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
|
|
9
|
+
config.deliver_inline = app_config.fetch(:deliver_inline, false)
|
|
10
|
+
|
|
11
|
+
# Delivery setup:
|
|
12
|
+
# Upkeep delivers committed changes on an in-process background dispatcher in
|
|
13
|
+
# every environment; no job backend is required. Broadcasts are standard
|
|
14
|
+
# Action Cable broadcasts, so multi-process deployments need a cross-process
|
|
15
|
+
# cable adapter (we recommend solid_cable) so a write handled by one process
|
|
16
|
+
# reaches browsers connected to another. Set config.deliver_inline = true in
|
|
17
|
+
# tests or console sessions that need delivery to run synchronously in the
|
|
18
|
+
# caller.
|
|
19
|
+
#
|
|
20
|
+
# Test setup:
|
|
21
|
+
# The generated test default is the in-process memory store. It follows the
|
|
22
|
+
# same subscription lifecycle as ActiveRecord without requiring subscription
|
|
23
|
+
# tables in every app test database. Keep at least one app/CI path on
|
|
24
|
+
# :active_record when you want to exercise durable subscription rows.
|
|
25
|
+
#
|
|
26
|
+
# View setup:
|
|
27
|
+
# No per-template annotations are required for ordinary Rails views. Upkeep
|
|
28
|
+
# instruments Action View templates as they render and adds the internal
|
|
29
|
+
# markers it needs for page roots, fragment roots, and safe partial collection
|
|
30
|
+
# render-site containers. Keep rendering normal ERB and collection partials:
|
|
31
|
+
#
|
|
32
|
+
# <ul>
|
|
33
|
+
# <%%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
34
|
+
# </ul>
|
|
35
|
+
#
|
|
36
|
+
# The upkeep_frame helper remains available for advanced/generated boundaries
|
|
37
|
+
# that cannot be derived from template source, but it is not part of normal
|
|
38
|
+
# application setup.
|
|
39
|
+
|
|
40
|
+
# Identity setup:
|
|
41
|
+
# Upkeep does not infer subscriber identity by naming convention. Declare each
|
|
42
|
+
# identity boundary that should partition live updates, and resolve the same
|
|
43
|
+
# boundary from ActionCable when the browser subscribes.
|
|
44
|
+
#
|
|
45
|
+
# Example for Current.user plus ApplicationCable identified_by :current_user:
|
|
46
|
+
#
|
|
47
|
+
# config.identify :viewer, current: ["Current", :user] do
|
|
48
|
+
# subscribe { |connection| connection.current_user }
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# Example for Devise/Warden current_user:
|
|
52
|
+
#
|
|
53
|
+
# config.identify :viewer, warden: :user do
|
|
54
|
+
# subscribe { |connection| connection.current_user }
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# Example for session-backed authentication:
|
|
58
|
+
#
|
|
59
|
+
# config.identify :viewer, session: :user_id do
|
|
60
|
+
# # nil means this identity boundary is absent. Use absent_if for any
|
|
61
|
+
# # additional app-specific sentinel, such as false or "guest".
|
|
62
|
+
# # absent_if { |value| value.nil? || value == false }
|
|
63
|
+
# subscribe { |connection| connection.session[:user_id] }
|
|
64
|
+
# end
|
|
65
|
+
end
|