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,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