upkeep-rails 0.1.0

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.

Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,192 @@
1
+ # Single Subscriber And Cold Fixed-Cost Roadmap
2
+
3
+ This roadmap targets the cases where Upkeep has little or no render sharing
4
+ amortization: one active subscriber, cold page setup, cable activation, and
5
+ short-lived anonymous pages.
6
+
7
+ Last updated: 2026-05-21.
8
+
9
+ Current code state:
10
+
11
+ - Base runtime/docs commit: `474fb9c` (`Instrument capture and document Upkeep Rails`).
12
+ - Current pass adds gated request/action profiling, operation-scoped capture
13
+ metrics, mutation action/delivery timings, client/server phase correlation,
14
+ benchmark phase labels, lightweight subscription-shape tracing, static
15
+ template metadata caching, shared-stream signature memoization, and queued
16
+ dependency flushing.
17
+ - Latest verification: `bundle exec rake test` passed with `186` runs and
18
+ `1172` assertions.
19
+ - Latest single-subscriber benchmark data is the 2026-05-21 identity-free
20
+ report below.
21
+
22
+ ## Environment
23
+
24
+ From a fresh zsh shell in any worktree:
25
+
26
+ ```sh
27
+ cd /path/to/upkeep-rails
28
+ eval "$(mise activate zsh)"
29
+ ruby -v
30
+ bundle exec rake test
31
+ ```
32
+
33
+ Do not use system Ruby. The repo expects Ruby from mise, currently `3.4.7`.
34
+
35
+ ## Baseline
36
+
37
+ Latest identity-free 1-subscriber report:
38
+
39
+ - Report: `benchmark/results/identity-free-feed-compare-20260521114026.md`
40
+ - Upkeep setup p95: `140ms`
41
+ - Turbo setup p95: `104ms`
42
+ - Upkeep page render p95: `91ms`
43
+ - Turbo page render p95: `73ms`
44
+ - Upkeep WebSocket connect p95: `38ms`
45
+ - Turbo WebSocket connect p95: `17ms`
46
+ - Upkeep subscribe call p95: `10ms`
47
+ - Turbo subscribe call p95: `14ms`
48
+ - Upkeep subscribe ack p95: `10ms`
49
+ - Turbo subscribe ack p95: `13ms`
50
+ - Upkeep write POST p95: `38.85ms`
51
+ - Turbo write POST p95: `66.37ms`
52
+ - Upkeep update-settled p95: `90ms`
53
+ - Turbo update-settled p95: `96ms`
54
+ - Subscription shape cache: `1` miss, `0` hits, `0` bypasses
55
+ - Shape miss timing: total `4.221ms`, key `1.743ms`, index template `1.003ms`
56
+ - Setup page server timing: Upkeep p95 `83.88ms`; Turbo p95 `66.02ms`
57
+ - Write request server timing: Upkeep p95 `23.78ms`; Turbo p95 `32.72ms`
58
+ - `GET /feed` request-capture timing: total p95 `82.921ms`; action p95
59
+ `70.594ms`; view p95 `55.578ms`; template p95 `42.352ms`; collection render
60
+ p95 `13.226ms`; SQL p95 `1.577ms`; register p95 `12.129ms`
61
+ - `GET /feed` recorder timing: dependency p95 `5.783ms`; frame p95 `2.418ms`;
62
+ shape trace p95 `4.264ms`
63
+ - Subscribe channel server timing: total `2.085ms`, activation `1.95ms`
64
+
65
+ With one subscriber there is still no render dedup savings. The latest cold
66
+ report is a one-sample fixed-cost probe and is noisier than the shared report.
67
+ It still shows the right target: the remaining gap is not SQL, shape cache hit
68
+ cost, or server-side channel subscribe. This pass reduced recorder dependency
69
+ and shape tracing substantially, so the dominant cold fixed cost is now the
70
+ first page/action path, especially Action View template and collection capture,
71
+ plus first registration.
72
+
73
+ ## Priorities
74
+
75
+ 1. Decompose cold page/capture setup
76
+
77
+ Upkeep page render p95 is now `91ms` versus Turbo `73ms`, while setup p95
78
+ is `140ms` versus Turbo `104ms`. `GET /feed` request-capture action p95 is
79
+ `70.594ms`, view p95 is `55.578ms`, and template p95 is `42.352ms`.
80
+
81
+ Candidate work:
82
+
83
+ - Reduce Action View capture overhead around template and collection render
84
+ instrumentation. SQL is only `1.735ms` in this run.
85
+ - Keep ActionCable open/confirmation separate from server-side channel
86
+ subscribe; latest server-side subscribe total is only `2.085ms`.
87
+ - Continue reducing Action View template capture overhead; template p95 is
88
+ `42.352ms` in the latest one-subscriber probe.
89
+ - Investigate whether page/layout frame capture can be represented with one
90
+ page boundary without losing layout identity safety.
91
+ - Keep queued dependency flushing; the recorder shape trace has already
92
+ dropped from `10.04ms` to `4.264ms`.
93
+
94
+ Target outcome:
95
+
96
+ - 1-subscriber page render p95 closes the current `18ms` gap to Turbo.
97
+ - Setup telemetry explains the remaining `36ms` setup gap.
98
+
99
+ 2. Finish shape miss/key cleanup
100
+
101
+ Shape keys no longer serialize the full recorder snapshot, and the
102
+ subscription-shape trace now records normal capture shape terms in
103
+ DAG/runtime. Shared-hit key p95 is `0.031ms`; the single-subscriber miss path
104
+ is still `1.743ms` and total miss is `4.221ms`, but it is a first-shape
105
+ cost.
106
+
107
+ Remaining work:
108
+
109
+ - Preserve the rolling trace digest for hot hits.
110
+ - Identify why first-shape key generation still costs `1.743ms`.
111
+ - Move template digest and manifest fingerprint normalization out of the
112
+ miss path where possible.
113
+ - Keep cache misses exact without replay-payload hashing.
114
+
115
+ Target outcome:
116
+
117
+ - Shape-key generation is sub-millisecond for both miss and hit paths.
118
+ - Cache misses stay exact, and false misses remain safe.
119
+
120
+ 3. Reduce subscribe ack fixed cost
121
+
122
+ Server-side subscribe is no longer the main cost, but client-observed suback
123
+ can still be noisy. Keep the server/client split visible.
124
+
125
+ Candidate work:
126
+
127
+ - Keep cable subscribe phase telemetry: fetch, authorization, activation,
128
+ active-index register, stream attach, confirmation.
129
+ - Keep benchmark phase labels on WebSocket open, cable open, subscription
130
+ registration, and confirmation.
131
+ - Keep durable writer and persistent index work outside the ack critical path.
132
+ - Preserve strict activation semantics; no compatibility shims for old rows.
133
+
134
+ Target outcome:
135
+
136
+ - Server-side subscribe p95 stays within a few milliseconds of Turbo in both
137
+ 1- and 200-subscriber identity-free reports.
138
+
139
+ 4. Single-subscriber write fast path
140
+
141
+ The latest one-subscriber run recovered on write and update-settled, but the
142
+ one-sample p95 is noisy. Keep this lane explicit: if planning resolves to
143
+ exactly one active subscriber, skip shared-group work that only pays off for
144
+ cohorts.
145
+
146
+ Candidate checks:
147
+
148
+ - No render dedup accounting on the hot path for a single target unless the
149
+ runtime already produced the shared group.
150
+ - No batch fanout structure for one connection.
151
+ - No persistent index dependency for active, in-process delivery.
152
+
153
+ Target outcome:
154
+
155
+ - 1-subscriber update-settled returns to at or below Turbo.
156
+ - Planning/build telemetry shows one lookup, one plan, one render, one
157
+ transmit.
158
+
159
+ 5. Cold benchmark separation
160
+
161
+ Keep cold burst capacity separate from warm update economics.
162
+
163
+ Benchmark shapes:
164
+
165
+ - Cold burst capacity: simultaneous new users, accepts, page render, cable
166
+ open, subscribe ack.
167
+ - Cold ramp setup: staged subscribers without accept queue loss.
168
+ - Warm single update: subscribers are ready, one mutation settles.
169
+
170
+ ## Success Criteria
171
+
172
+ - 1-subscriber first-write miss behavior is explained and bounded.
173
+ - `steady_state_setup_leaks` is zero in warm single-subscriber runs.
174
+ - 1-subscriber setup p95 closes most of the current `140ms` versus Turbo
175
+ `104ms` gap.
176
+ - 1-subscriber page render p95 closes most of the current `91ms` versus Turbo
177
+ `73ms` gap.
178
+ - Shape miss telemetry shows where first-render cost remains.
179
+ - 1-subscriber update-settled p95 remains at or below Turbo.
180
+
181
+ ## Validation
182
+
183
+ Use the mise environment in every shell:
184
+
185
+ ```sh
186
+ eval "$(mise activate zsh)"
187
+ bundle exec rake test
188
+ BENCH_FAMILY=render_dedup BENCH_WORKLOAD=identity_free_feed_compare BENCH_TIER=report BENCH_VUS=1 ruby benchmark/bin/run
189
+ ```
190
+
191
+ When changing cold setup behavior, also run the relevant cold benchmark instead
192
+ of using the warm shared report as a proxy.
data/docs/testing.md ADDED
@@ -0,0 +1,113 @@
1
+ # Testing
2
+
3
+ The Rails package green bar includes the gem tests, the maintained benchmark
4
+ apps, and the proof runner. The benchmark apps live inside this repo under
5
+ `benchmark/`.
6
+
7
+ ## Commands
8
+
9
+ Run the gem test suite:
10
+
11
+ ```sh
12
+ mise exec -- ruby -S rake test
13
+ ```
14
+
15
+ Run the full gate:
16
+
17
+ ```sh
18
+ mise exec -- ruby bin/test
19
+ ```
20
+
21
+ `bin/test` runs:
22
+
23
+ - all tests under `test/`;
24
+ - `benchmark/upkeep-app`'s Rails test suite;
25
+ - `benchmark/turbo-app`'s Rails test suite;
26
+ - `bin/run`, which writes proof reports to `results/`.
27
+
28
+ Run the proof runner directly:
29
+
30
+ ```sh
31
+ mise exec -- ruby bin/run
32
+ ```
33
+
34
+ ## What The Gate Covers
35
+
36
+ Gem tests cover:
37
+
38
+ - Action View frame capture and replay;
39
+ - controller request replay;
40
+ - Active Record/Arel query dependency analysis;
41
+ - subscription storage and reverse-index lookups;
42
+ - refused opaque collection boundaries;
43
+ - column-scoped collection lookup keys;
44
+ - invalidation planning;
45
+ - Turbo Stream delivery partitioning;
46
+ - render grouping before replay;
47
+ - ActionCable subscriber identity and channel behavior;
48
+ - transport retry and backpressure behavior.
49
+
50
+ The Upkeep benchmark app covers:
51
+
52
+ - authenticated board rendering;
53
+ - room rendering;
54
+ - shared feed rendering;
55
+ - authorization boundaries;
56
+ - helper-hidden render idioms;
57
+ - automatic subscription registration;
58
+ - streamed delivery through canonical subscriber streams.
59
+
60
+ The Turbo benchmark app remains the comparison app for workload parity.
61
+
62
+ ## App-Level Assertions
63
+
64
+ Maintained Rails apps should assert that successful HTML GETs register a
65
+ subscription:
66
+
67
+ ```ruby
68
+ include Upkeep::Rails::Testing
69
+
70
+ get board_path(board)
71
+ assert_response :success
72
+ assert_upkeep_subscription_registered
73
+ ```
74
+
75
+ They should also assert that unauthorized bytes do not render:
76
+
77
+ ```ruby
78
+ get board_path(private_board)
79
+ assert_response :forbidden
80
+ refute_includes response.body, "Private card"
81
+ ```
82
+
83
+ For streamed delivery, include `ActionCable::TestHelper`, capture the
84
+ registered stream names through `Upkeep::Rails::Testing`, perform a mutation,
85
+ drain delivery, and assert the broadcast payload:
86
+
87
+ ```ruby
88
+ include ActionCable::TestHelper
89
+ include Upkeep::Rails::Testing
90
+
91
+ broadcasts = capture_upkeep_broadcasts do
92
+ patch board_card_path(board, card), params: { card: { title: "Updated" } }
93
+ assert_response :ok
94
+ Upkeep::Rails.drain_delivery!
95
+ end
96
+
97
+ assert_includes broadcasts.join, "Updated"
98
+ refute_includes broadcasts.join, "Other subscriber secret"
99
+ ```
100
+
101
+ ## Result Reports
102
+
103
+ The proof runner writes:
104
+
105
+ - `results/herb_surface.json`
106
+ - `results/active_record_surface.json`
107
+ - `results/end_to_end_proof.json`
108
+ - `results/identity_safety_proof.json`
109
+ - `results/auth_surfaces_proof.json`
110
+
111
+ These reports are debugging artifacts for maintained app work. They are useful
112
+ when a change alters frame coverage, dependency metadata, identity
113
+ partitioning, or target selection.
@@ -0,0 +1,90 @@
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
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_subscription_migration
18
+ return if migration_exists?("create_upkeep_subscriptions")
19
+
20
+ @migration_version = ActiveRecord::Migration.current_version
21
+ migration_template "create_upkeep_subscriptions.rb.erb", "db/migrate/create_upkeep_subscriptions.rb"
22
+ end
23
+
24
+ def create_initializer
25
+ template "upkeep.rb", "config/initializers/upkeep.rb"
26
+ end
27
+
28
+ def create_browser_bootstrap
29
+ template "subscription.js", "app/javascript/upkeep/subscription.js"
30
+ append_application_import
31
+ pin_action_cable
32
+ end
33
+
34
+ def mount_action_cable
35
+ return if routes_path.exist? && routes_path.read.include?("ActionCable.server")
36
+
37
+ route %(mount ActionCable.server => "/cable")
38
+ end
39
+
40
+ private
41
+
42
+ def migration_exists?(name)
43
+ Dir.glob(destination_path("db/migrate/*.rb")).any? do |path|
44
+ File.basename(path).include?(name)
45
+ end
46
+ end
47
+
48
+ def append_application_import
49
+ return unless application_js_path.exist?
50
+
51
+ append_import("@hotwired/turbo-rails")
52
+ append_import("./upkeep/subscription")
53
+ end
54
+
55
+ def pin_action_cable
56
+ return unless importmap_path.exist?
57
+
58
+ pin_importmap("@hotwired/turbo-rails", "turbo.min.js")
59
+ pin_importmap("@rails/actioncable", "actioncable.esm.js")
60
+ end
61
+
62
+ def append_import(specifier)
63
+ return if application_js_path.read.include?(specifier)
64
+
65
+ append_to_file application_js_path.to_s, %(import "#{specifier}"\n)
66
+ end
67
+
68
+ def pin_importmap(specifier, asset)
69
+ return if importmap_path.read.include?(%("#{specifier}"))
70
+
71
+ append_to_file importmap_path.to_s, %(pin "#{specifier}", to: "#{asset}"\n)
72
+ end
73
+
74
+ def routes_path
75
+ Pathname(destination_path("config/routes.rb"))
76
+ end
77
+
78
+ def application_js_path
79
+ Pathname(destination_path("app/javascript/application.js"))
80
+ end
81
+
82
+ def importmap_path
83
+ Pathname(destination_path("config/importmap.rb"))
84
+ end
85
+
86
+ def destination_path(path)
87
+ File.join(destination_root, path)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,31 @@
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.timestamps
8
+ end
9
+ add_index :upkeep_subscriptions, :subscriber_id
10
+
11
+ create_table :upkeep_subscription_index_entries do |t|
12
+ t.string :subscription_id, null: false
13
+ t.string :lookup_key_digest, null: false
14
+ t.string :dependency_source, null: false
15
+ t.string :lookup_table, null: false
16
+ t.json :lookup_record_id_snapshot
17
+ t.string :lookup_attribute, null: false
18
+ t.string :dependency_table, null: false
19
+ t.string :dependency_predicate_digest
20
+ t.json :dependency_metadata_snapshot
21
+ t.json :owner_ids_snapshot, null: false
22
+ t.timestamps
23
+ end
24
+ add_index :upkeep_subscription_index_entries, :subscription_id
25
+ add_index :upkeep_subscription_index_entries, :lookup_key_digest
26
+ add_foreign_key :upkeep_subscription_index_entries,
27
+ :upkeep_subscriptions,
28
+ column: :subscription_id,
29
+ on_delete: :cascade
30
+ end
31
+ end
@@ -0,0 +1,107 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+ import { Turbo } from "@hotwired/turbo-rails"
3
+
4
+ const MARKER_SELECTOR = "script[data-upkeep-subscription]"
5
+
6
+ let consumer
7
+ const subscriptions = new Map()
8
+
9
+ function cableConsumer() {
10
+ consumer ||= createConsumer()
11
+ return consumer
12
+ }
13
+
14
+ function markerPayloads() {
15
+ return Array.from(document.querySelectorAll(MARKER_SELECTOR)).map((marker) =>
16
+ JSON.parse(marker.textContent || "{}")
17
+ )
18
+ }
19
+
20
+ function currentSubscriptionIds() {
21
+ return new Set(markerPayloads().map((payload) => payload.subscription_id).filter(Boolean))
22
+ }
23
+
24
+ function applyTurboStreams(html) {
25
+ if (applyDocumentPageStream(html)) return
26
+
27
+ if (Turbo?.renderStreamMessage) {
28
+ Turbo.renderStreamMessage(String(html))
29
+ return
30
+ }
31
+
32
+ const template = document.createElement("template")
33
+ template.innerHTML = String(html)
34
+
35
+ template.content.querySelectorAll("turbo-stream").forEach((stream) => {
36
+ document.body.appendChild(stream)
37
+ })
38
+ }
39
+
40
+ function applyDocumentPageStream(html) {
41
+ const template = document.createElement("template")
42
+ template.innerHTML = String(html)
43
+
44
+ const stream = Array.from(template.content.querySelectorAll("turbo-stream")).find((candidate) =>
45
+ targetsDocumentElement(candidate)
46
+ )
47
+ if (!stream) return false
48
+
49
+ const nextDocument = stream.querySelector("template")?.innerHTML
50
+ if (!nextDocument) return false
51
+
52
+ document.open()
53
+ document.write(nextDocument)
54
+ document.close()
55
+ return true
56
+ }
57
+
58
+ function targetsDocumentElement(stream) {
59
+ const selector = stream.getAttribute("targets") || stream.getAttribute("target")
60
+ if (!selector) return false
61
+
62
+ try {
63
+ return Array.from(document.querySelectorAll(selector)).includes(document.documentElement)
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ function subscribe(payload) {
70
+ if (!payload.subscription_id || subscriptions.has(payload.subscription_id)) return
71
+
72
+ const subscription = cableConsumer().subscriptions.create(
73
+ {
74
+ channel: payload.channel || "Upkeep::Rails::Cable::Channel",
75
+ subscription_id: payload.subscription_id
76
+ },
77
+ {
78
+ received(data) {
79
+ applyTurboStreams(data)
80
+ }
81
+ }
82
+ )
83
+
84
+ subscriptions.set(payload.subscription_id, subscription)
85
+ }
86
+
87
+ function unsubscribeMissing() {
88
+ const liveIds = currentSubscriptionIds()
89
+
90
+ subscriptions.forEach((subscription, subscriptionId) => {
91
+ if (liveIds.has(subscriptionId)) return
92
+
93
+ subscription.unsubscribe()
94
+ subscriptions.delete(subscriptionId)
95
+ })
96
+ }
97
+
98
+ export function connectUpkeepSubscriptions() {
99
+ markerPayloads().forEach(subscribe)
100
+ unsubscribeMissing()
101
+ }
102
+
103
+ document.addEventListener("DOMContentLoaded", connectUpkeepSubscriptions)
104
+ document.addEventListener("turbo:load", connectUpkeepSubscriptions)
105
+ document.addEventListener("turbo:render", connectUpkeepSubscriptions)
106
+
107
+ connectUpkeepSubscriptions()
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ config.upkeep.enabled = true
5
+ config.upkeep.subscription_store = :active_record
6
+ end