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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. 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