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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -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/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -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 +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -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 +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -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 +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -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 +53 -0
- 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()
|