upkeep-rails 0.1.9
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 +311 -0
- data/docs/how-it-works.md +269 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +392 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +550 -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 +518 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +920 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +154 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +197 -0
- data/lib/upkeep/rails/testing.rb +258 -0
- data/lib/upkeep/rails.rb +370 -0
- data/lib/upkeep/replay.rb +439 -0
- data/lib/upkeep/runtime.rb +1202 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +375 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +308 -0
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
def show_identity_setup_guidance
|
|
41
|
+
usages = detected_identity_usages
|
|
42
|
+
return if usages.empty?
|
|
43
|
+
|
|
44
|
+
say "\nIdentity setup required", :yellow
|
|
45
|
+
say "Upkeep found request-side identity usage:"
|
|
46
|
+
usages.each { |usage| say " #{usage}" }
|
|
47
|
+
say "Upkeep does not infer subscriber identity by naming convention."
|
|
48
|
+
say "Add an explicit Upkeep::Rails.configure identity mapping in config/initializers/upkeep.rb."
|
|
49
|
+
say "Pages that depend on undeclared non-absent CurrentAttributes or Warden identities are refused for live updates."
|
|
50
|
+
say ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def migration_exists?(name)
|
|
56
|
+
Dir.glob(destination_path("db/migrate/*.rb")).any? do |path|
|
|
57
|
+
File.basename(path).include?(name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def append_application_import
|
|
62
|
+
return unless application_js_path.exist?
|
|
63
|
+
|
|
64
|
+
append_import("@hotwired/turbo-rails")
|
|
65
|
+
append_import("upkeep/subscription")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def pin_action_cable
|
|
69
|
+
return unless importmap_path.exist?
|
|
70
|
+
|
|
71
|
+
pin_importmap("@hotwired/turbo-rails", "turbo.min.js")
|
|
72
|
+
pin_importmap("@rails/actioncable", "actioncable.esm.js")
|
|
73
|
+
pin_importmap("upkeep/subscription", "upkeep/subscription.js")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def append_import(specifier)
|
|
77
|
+
return if application_js_path.read.include?(specifier)
|
|
78
|
+
|
|
79
|
+
append_to_file application_js_path.to_s, %(import "#{specifier}"\n)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pin_importmap(specifier, asset)
|
|
83
|
+
return if importmap_path.read.include?(%("#{specifier}"))
|
|
84
|
+
|
|
85
|
+
append_to_file importmap_path.to_s, %(pin "#{specifier}", to: "#{asset}"\n)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def detected_identity_usages
|
|
89
|
+
usages = []
|
|
90
|
+
source = application_source
|
|
91
|
+
usages << "Current.user" if source.match?(/\bCurrent\.user\b/)
|
|
92
|
+
usages << "session[:user_id]" if source.match?(/\bsession\[(?::user_id|['"]user_id['"])\]/)
|
|
93
|
+
usages << "warden.user" if source.match?(/\bwarden\.user\b/)
|
|
94
|
+
usages.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def application_source
|
|
98
|
+
app_paths.filter_map do |path|
|
|
99
|
+
File.read(path)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
nil
|
|
102
|
+
end.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def app_paths
|
|
106
|
+
Dir.glob(destination_path("app/**/*")).select do |path|
|
|
107
|
+
File.file?(path) && %w[.rb .erb].include?(File.extname(path))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def routes_path
|
|
112
|
+
Pathname(destination_path("config/routes.rb"))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def application_js_path
|
|
116
|
+
Pathname(destination_path("app/javascript/application.js"))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def importmap_path
|
|
120
|
+
Pathname(destination_path("config/importmap.rb"))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def destination_path(path)
|
|
124
|
+
File.join(destination_root, path)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
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,99 @@
|
|
|
1
|
+
import { createConsumer } from "@rails/actioncable"
|
|
2
|
+
import { Turbo } from "@hotwired/turbo-rails"
|
|
3
|
+
|
|
4
|
+
const SOURCE_ELEMENT = "upkeep-subscription-source"
|
|
5
|
+
const SOURCE_SELECTOR = `${SOURCE_ELEMENT}[data-upkeep-subscription]`
|
|
6
|
+
|
|
7
|
+
let consumer
|
|
8
|
+
|
|
9
|
+
function cableConsumer() {
|
|
10
|
+
consumer ||= createConsumer()
|
|
11
|
+
return consumer
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parsePayload(element) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(element.textContent || "{}")
|
|
17
|
+
} catch {
|
|
18
|
+
return {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderStreamMessage(data) {
|
|
23
|
+
if (Turbo?.renderStreamMessage) {
|
|
24
|
+
Turbo.renderStreamMessage(String(data))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class UpkeepSubscriptionSourceElement extends HTMLElement {
|
|
29
|
+
connectedCallback() {
|
|
30
|
+
this.connectStreamSource()
|
|
31
|
+
this.subscribe()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
disconnectedCallback() {
|
|
35
|
+
this.unsubscribe()
|
|
36
|
+
this.disconnectStreamSource()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
connectStreamSource() {
|
|
40
|
+
if (this.streamSourceConnected) return
|
|
41
|
+
if (!Turbo?.session?.connectStreamSource) return
|
|
42
|
+
|
|
43
|
+
Turbo.session.connectStreamSource(this)
|
|
44
|
+
this.streamSourceConnected = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnectStreamSource() {
|
|
48
|
+
if (!this.streamSourceConnected) return
|
|
49
|
+
|
|
50
|
+
Turbo.session.disconnectStreamSource(this)
|
|
51
|
+
this.streamSourceConnected = false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
subscribe() {
|
|
55
|
+
const payload = parsePayload(this)
|
|
56
|
+
if (!payload.subscription_id || this.subscription) return
|
|
57
|
+
|
|
58
|
+
this.subscription = cableConsumer().subscriptions.create(
|
|
59
|
+
{
|
|
60
|
+
channel: payload.channel || "Upkeep::Rails::Cable::Channel",
|
|
61
|
+
subscription_id: payload.subscription_id,
|
|
62
|
+
activation_token: payload.activation_token
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
received: (data) => this.receive(data),
|
|
66
|
+
|
|
67
|
+
rejected: () => {
|
|
68
|
+
console.error(
|
|
69
|
+
"[upkeep] subscription rejected by the server; refresh app/javascript/upkeep/subscription.js if this started after upgrading upkeep-rails",
|
|
70
|
+
{ subscription_id: payload.subscription_id, channel: payload.channel || "Upkeep::Rails::Cable::Channel" }
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
unsubscribe() {
|
|
78
|
+
if (!this.subscription) return
|
|
79
|
+
|
|
80
|
+
this.subscription.unsubscribe()
|
|
81
|
+
this.subscription = null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
receive(data) {
|
|
85
|
+
if (this.streamSourceConnected) {
|
|
86
|
+
this.dispatchEvent(new MessageEvent("message", { data: String(data) }))
|
|
87
|
+
} else {
|
|
88
|
+
renderStreamMessage(data)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!customElements.get(SOURCE_ELEMENT)) {
|
|
94
|
+
customElements.define(SOURCE_ELEMENT, UpkeepSubscriptionSourceElement)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function connectUpkeepSubscriptions() {
|
|
98
|
+
document.querySelectorAll(SOURCE_SELECTOR).forEach((source) => source.subscribe?.())
|
|
99
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
|
|
8
|
+
config.delivery_adapter = app_config.fetch(:delivery_adapter, Rails.env.production? ? :active_job : :async)
|
|
9
|
+
config.delivery_queue = app_config.fetch(:delivery_queue, :upkeep_realtime)
|
|
10
|
+
|
|
11
|
+
# Delivery setup:
|
|
12
|
+
# Upkeep uses Active Job for committed-change delivery in production and async
|
|
13
|
+
# in development/test. Configure your app's Active Job backend normally
|
|
14
|
+
# (Solid Queue, Sidekiq, GoodJob, etc.) and configure ActionCable with a shared
|
|
15
|
+
# adapter such as Solid Cable, Redis, or PostgreSQL so worker broadcasts can
|
|
16
|
+
# reach web socket connections.
|
|
17
|
+
#
|
|
18
|
+
# Test setup:
|
|
19
|
+
# The generated test default is the in-process memory store. It follows the
|
|
20
|
+
# same subscription lifecycle as ActiveRecord without requiring subscription
|
|
21
|
+
# tables in every app test database. Keep at least one app/CI path on
|
|
22
|
+
# :active_record when you want to exercise durable subscription rows.
|
|
23
|
+
#
|
|
24
|
+
# View setup:
|
|
25
|
+
# No per-template annotations are required for ordinary Rails views. Upkeep
|
|
26
|
+
# instruments Action View templates as they render and adds the internal
|
|
27
|
+
# markers it needs for page roots, fragment roots, and safe partial collection
|
|
28
|
+
# render-site containers. Keep rendering normal ERB and collection partials:
|
|
29
|
+
#
|
|
30
|
+
# <ul>
|
|
31
|
+
# <%%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
32
|
+
# </ul>
|
|
33
|
+
#
|
|
34
|
+
# The upkeep_frame helper remains available for advanced/generated boundaries
|
|
35
|
+
# that cannot be derived from template source, but it is not part of normal
|
|
36
|
+
# application setup.
|
|
37
|
+
|
|
38
|
+
# Identity setup:
|
|
39
|
+
# Upkeep does not infer subscriber identity by naming convention. Declare each
|
|
40
|
+
# identity boundary that should partition live updates, and resolve the same
|
|
41
|
+
# boundary from ActionCable when the browser subscribes.
|
|
42
|
+
#
|
|
43
|
+
# Example for Current.user plus ApplicationCable identified_by :current_user:
|
|
44
|
+
#
|
|
45
|
+
# config.identify :viewer, current: ["Current", :user] do
|
|
46
|
+
# subscribe { |connection| connection.current_user }
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# Example for Devise/Warden current_user:
|
|
50
|
+
#
|
|
51
|
+
# config.identify :viewer, warden: :user do
|
|
52
|
+
# subscribe { |connection| connection.current_user }
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# Example for session-backed authentication:
|
|
56
|
+
#
|
|
57
|
+
# config.identify :viewer, session: :user_id do
|
|
58
|
+
# # nil means this identity boundary is absent. Use absent_if for any
|
|
59
|
+
# # additional app-specific sentinel, such as false or "guest".
|
|
60
|
+
# # absent_if { |value| value.nil? || value == false }
|
|
61
|
+
# subscribe { |connection| connection.session[:user_id] }
|
|
62
|
+
# end
|
|
63
|
+
end
|