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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reverse_index"
4
+ require_relative "store"
5
+
6
+ module Upkeep
7
+ module Subscriptions
8
+ class ActiveRegistry
9
+ def initialize
10
+ @mutex = Mutex.new
11
+ @subscriptions = {}
12
+ @reverse_index = ReverseIndex.new
13
+ end
14
+
15
+ def register(subscription, entries: nil)
16
+ @mutex.synchronize do
17
+ @subscriptions[subscription.id] = subscription
18
+ if entries
19
+ @reverse_index.index_entries(entries, subscription: subscription)
20
+ else
21
+ @reverse_index.index(subscription)
22
+ end
23
+ end
24
+ end
25
+
26
+ def fetch(id)
27
+ @mutex.synchronize { @subscriptions[id] }
28
+ end
29
+
30
+ def subscriptions
31
+ @mutex.synchronize { @subscriptions.values }
32
+ end
33
+
34
+ def unregister(ids)
35
+ ids = Array(ids)
36
+ @mutex.synchronize do
37
+ ids.each do |id|
38
+ next unless @subscriptions.delete(id)
39
+
40
+ @reverse_index.delete_subscription(id)
41
+ end
42
+ end
43
+ end
44
+
45
+ def touch(id, metadata:)
46
+ @mutex.synchronize do
47
+ subscription = @subscriptions[id]
48
+ return unless subscription
49
+
50
+ @subscriptions[id] = Subscription.new(
51
+ subscription.id,
52
+ subscription.subscriber_id,
53
+ subscription.recorder,
54
+ subscription.graph,
55
+ subscription.metadata.merge(metadata)
56
+ )
57
+ end
58
+ end
59
+
60
+ def entries_for(changes)
61
+ @mutex.synchronize { @reverse_index.entries_for(changes) }
62
+ end
63
+
64
+ def reset
65
+ @mutex.synchronize do
66
+ @subscriptions = {}
67
+ @reverse_index = ReverseIndex.new
68
+ end
69
+ end
70
+
71
+ def covers?(persistent_count)
72
+ count >= persistent_count
73
+ end
74
+
75
+ def count
76
+ @mutex.synchronize { @subscriptions.size }
77
+ end
78
+
79
+ def summary
80
+ @mutex.synchronize do
81
+ @reverse_index.summary.merge(subscriptions: @subscriptions.size)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def rebuild_reverse_index!
88
+ @reverse_index = ReverseIndex.new
89
+ @subscriptions.each_value { |subscription| @reverse_index.index(subscription) }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upkeep
4
+ module Subscriptions
5
+ class AsyncDurableWriter
6
+ DEFAULT_BATCH_SIZE = 100
7
+ DEFAULT_FLUSH_INTERVAL = 1.0
8
+ Job = Data.define(:subscription, :entries, :operation)
9
+
10
+ def initialize(batch_size: DEFAULT_BATCH_SIZE, flush_interval: DEFAULT_FLUSH_INTERVAL, &persist_batch)
11
+ @batch_size = batch_size
12
+ @flush_interval = flush_interval
13
+ @persist_batch = persist_batch
14
+ @mutex = Mutex.new
15
+ @available = ConditionVariable.new
16
+ @drained = ConditionVariable.new
17
+ @queue = []
18
+ @pending = 0
19
+ @inflight_ids = Hash.new(0)
20
+ @closed = false
21
+ @flush_now = false
22
+ @errors = []
23
+ @worker = Thread.new { work_loop }
24
+ @worker.name = "upkeep-durable-writer" if @worker.respond_to?(:name=)
25
+ end
26
+
27
+ def enqueue(subscription, entries:, operation:)
28
+ @mutex.synchronize do
29
+ raise IOError, "Upkeep durable writer is closed" if @closed
30
+
31
+ @queue << Job.new(subscription, entries, operation)
32
+ @pending += 1
33
+ @available.signal
34
+ end
35
+ end
36
+
37
+ def cancel(ids)
38
+ ids = Array(ids)
39
+ return [] if ids.empty?
40
+
41
+ requested_ids = ids.to_h { |id| [id, true] }
42
+
43
+ @mutex.synchronize do
44
+ queued_ids = {}
45
+ removed = 0
46
+ @queue.delete_if do |job|
47
+ id = job.subscription.id
48
+ requested_ids.key?(id).tap do |matched|
49
+ if matched
50
+ queued_ids[id] = true
51
+ removed += 1
52
+ end
53
+ end
54
+ end
55
+ @pending -= removed
56
+ @drained.broadcast if @pending.zero?
57
+ persisted_ids = ids.reject { |id| queued_ids[id] }
58
+ @drained.wait(@mutex) while persisted_ids.any? { |id| @inflight_ids.fetch(id, 0).positive? }
59
+ persisted_ids
60
+ end
61
+ end
62
+
63
+ def drain(raise_errors: true)
64
+ errors = @mutex.synchronize do
65
+ @flush_now = true
66
+ @available.broadcast
67
+ @drained.wait(@mutex) while @pending.positive?
68
+ drained_errors = @errors
69
+ @errors = [] if raise_errors
70
+ drained_errors
71
+ end
72
+
73
+ raise errors.first if raise_errors && errors.any?
74
+
75
+ errors
76
+ end
77
+
78
+ def shutdown
79
+ drain(raise_errors: false)
80
+ @mutex.synchronize do
81
+ @closed = true
82
+ @available.broadcast
83
+ end
84
+ @worker.join
85
+ end
86
+
87
+ private
88
+
89
+ def work_loop
90
+ loop do
91
+ batch = next_batch
92
+ break unless batch
93
+
94
+ begin
95
+ @persist_batch.call(batch)
96
+ rescue StandardError => error
97
+ @mutex.synchronize { @errors << error }
98
+ ensure
99
+ @mutex.synchronize do
100
+ batch.each do |job|
101
+ id = job.subscription.id
102
+ @inflight_ids[id] -= 1
103
+ @inflight_ids.delete(id) unless @inflight_ids[id].positive?
104
+ end
105
+ @pending -= batch.size
106
+ @drained.broadcast if @pending.zero?
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def next_batch
113
+ @mutex.synchronize do
114
+ @available.wait(@mutex) while @queue.empty? && !@closed
115
+ return nil if @queue.empty? && @closed
116
+
117
+ wait_for_batch_fill
118
+ @flush_now = false
119
+ @queue.shift(@batch_size).tap do |batch|
120
+ batch.each { |job| @inflight_ids[job.subscription.id] += 1 }
121
+ end
122
+ end
123
+ end
124
+
125
+ def wait_for_batch_fill
126
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @flush_interval
127
+ while @queue.size < @batch_size && !@closed && !@flush_now
128
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+ break unless remaining.positive?
130
+
131
+ @available.wait(@mutex, remaining)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ module JsonSnapshot
8
+ VERSION = 2
9
+ VERSION_KEY = "__upkeep_snapshot_version"
10
+ VALUE_KEY = "value"
11
+ SYMBOL_VALUE_KEY = "$sym"
12
+ SYMBOL_KEY_PREFIX = "$sym:"
13
+ STRING_KEY_PREFIX = "$str:"
14
+ JSON_KEY_PREFIX = "$json:"
15
+ RESERVED_STRING_KEYS = [SYMBOL_VALUE_KEY].freeze
16
+ RESERVED_STRING_KEY_PREFIXES = [SYMBOL_KEY_PREFIX, STRING_KEY_PREFIX, JSON_KEY_PREFIX].freeze
17
+
18
+ module_function
19
+
20
+ def dump(value)
21
+ {
22
+ VERSION_KEY => VERSION,
23
+ VALUE_KEY => encode(value)
24
+ }
25
+ end
26
+
27
+ def load(snapshot)
28
+ snapshot = JSON.parse(snapshot) if snapshot.is_a?(String)
29
+ version = snapshot.fetch(VERSION_KEY)
30
+ unless version.to_i == VERSION
31
+ raise ArgumentError, "unsupported Upkeep JSON snapshot version: #{version.inspect}"
32
+ end
33
+
34
+ decode(snapshot.fetch(VALUE_KEY))
35
+ end
36
+
37
+ def encode(value)
38
+ case value
39
+ when Symbol
40
+ { SYMBOL_VALUE_KEY => value.to_s }
41
+ when Hash
42
+ value.each_with_object({}) { |(key, nested_value), encoded| encoded[encode_key(key)] = encode(nested_value) }
43
+ when Array
44
+ value.map { |nested_value| encode(nested_value) }
45
+ when nil, true, false, Numeric, String
46
+ value
47
+ else
48
+ raise TypeError, "cannot persist #{value.class.name} in an Upkeep JSON snapshot"
49
+ end
50
+ end
51
+
52
+ def decode(value)
53
+ case value
54
+ when Hash
55
+ return value.fetch(SYMBOL_VALUE_KEY).to_sym if value.size == 1 && value.key?(SYMBOL_VALUE_KEY)
56
+
57
+ value.each_with_object({}) do |(key, nested_value), decoded|
58
+ decoded[decode_key(key)] = decode(nested_value)
59
+ end
60
+ when Array
61
+ value.map { |nested_value| decode(nested_value) }
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ def encode_key(key)
68
+ case key
69
+ when Symbol
70
+ "#{SYMBOL_KEY_PREFIX}#{key}"
71
+ when String
72
+ reserved_string_key?(key) ? "#{STRING_KEY_PREFIX}#{key}" : key
73
+ when nil, true, false, Numeric
74
+ "#{JSON_KEY_PREFIX}#{JSON.generate(encode(key))}"
75
+ else
76
+ raise TypeError, "cannot persist #{key.class.name} as an Upkeep JSON snapshot key"
77
+ end
78
+ end
79
+
80
+ def decode_key(key)
81
+ if key.start_with?(SYMBOL_KEY_PREFIX)
82
+ key.delete_prefix(SYMBOL_KEY_PREFIX).to_sym
83
+ elsif key.start_with?(STRING_KEY_PREFIX)
84
+ key.delete_prefix(STRING_KEY_PREFIX)
85
+ elsif key.start_with?(JSON_KEY_PREFIX)
86
+ decode(JSON.parse(key.delete_prefix(JSON_KEY_PREFIX)))
87
+ else
88
+ key
89
+ end
90
+ end
91
+
92
+ def reserved_string_key?(key)
93
+ RESERVED_STRING_KEYS.include?(key) || RESERVED_STRING_KEY_PREFIXES.any? { |prefix| key.start_with?(prefix) }
94
+ end
95
+
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ class LayeredReverseIndex
8
+ LOOKUP_NOTIFICATION = "lookup_subscription_index.upkeep"
9
+
10
+ def initialize(active_index:, persistent_index:, persistent_count:, store:, pending_index: nil)
11
+ @active_index = active_index
12
+ @persistent_index = persistent_index
13
+ @persistent_count = persistent_count
14
+ @store = store
15
+ @pending_index = pending_index
16
+ end
17
+
18
+ def entries_for(changes)
19
+ if ActiveSupport::Notifications.notifier.listening?(LOOKUP_NOTIFICATION)
20
+ payload = { changes: Array(changes).size, store: store }
21
+ ActiveSupport::Notifications.instrument(LOOKUP_NOTIFICATION, payload) do
22
+ entries_for_with_payload(changes, payload)
23
+ end
24
+ else
25
+ entries_for_without_payload(changes)
26
+ end
27
+ end
28
+
29
+ def entries_for_without_payload(changes)
30
+ active_entries = active_index.entries_for(changes)
31
+ return persistent_index.entries_for(changes) if active_index.count.zero?
32
+ return active_entries if active_index.covers?(persistent_subscription_count)
33
+
34
+ merge_entries(active_entries, persistent_index.entries_for(changes))
35
+ end
36
+
37
+ def entries_for_with_payload(changes, payload)
38
+ active_entries = active_index.entries_for(changes)
39
+ active_count = active_index.count
40
+ pending_entries = pending_entries_for(changes)
41
+ pending_count = pending_count_for_payload
42
+ payload[:active_entries] = active_entries.size
43
+ payload[:active_subscriptions] = active_count
44
+ payload[:pending_entries] = pending_entries.size
45
+ payload[:pending_subscriptions] = pending_count
46
+
47
+ if active_count.zero?
48
+ persistent_entries = persistent_index.entries_for(changes)
49
+ payload[:mode] = persistent_entries.empty? && pending_entries.any? ? "pending_activation" : "persistent"
50
+ payload[:persistent_entries] = persistent_entries.size
51
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
52
+ return persistent_entries
53
+ end
54
+
55
+ if active_index.covers?(persistent_subscription_count)
56
+ payload[:mode] = "active"
57
+ payload[:persistent_entries] = 0
58
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: [], pending_entries: pending_entries)
59
+ return active_entries
60
+ end
61
+
62
+ persistent_entries = persistent_index.entries_for(changes)
63
+ payload[:mode] = "active_and_persistent"
64
+ payload[:persistent_entries] = persistent_entries.size
65
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
66
+ merge_entries(active_entries, persistent_entries)
67
+ end
68
+
69
+ def summary
70
+ persistent = persistent_index.summary
71
+ active = active_index.summary
72
+ pending = pending_index&.summary || { lookup_keys: 0, entries: 0, subscriptions: 0 }
73
+ mode = active_index.covers?(persistent_subscription_count) ? :active : :active_and_persistent
74
+ totals = if mode == :active
75
+ active
76
+ else
77
+ {
78
+ lookup_keys: active.fetch(:lookup_keys) + persistent.fetch(:lookup_keys),
79
+ entries: active.fetch(:entries) + persistent.fetch(:entries)
80
+ }
81
+ end
82
+
83
+ {
84
+ lookup_keys: totals.fetch(:lookup_keys),
85
+ entries: totals.fetch(:entries),
86
+ mode: mode,
87
+ active: active,
88
+ pending: pending,
89
+ persistent: persistent
90
+ }
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :active_index, :persistent_index, :persistent_count, :store, :pending_index
96
+
97
+ def persistent_subscription_count
98
+ persistent_count.call
99
+ end
100
+
101
+ def merge_entries(active_entries, persistent_entries)
102
+ (active_entries + persistent_entries).uniq do |entry|
103
+ [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
104
+ end
105
+ end
106
+
107
+ def pending_entries_for(changes)
108
+ pending_index ? pending_index.entries_for(changes) : []
109
+ end
110
+
111
+ def pending_count_for_payload
112
+ pending_index&.count || 0
113
+ end
114
+
115
+ def apply_miss_reason(payload, active_entries:, persistent_entries:, pending_entries:)
116
+ return if active_entries.any? || persistent_entries.any?
117
+
118
+ payload[:miss_reason] = pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "../dependencies"
6
+ require_relative "json_snapshot"
7
+ require_relative "reverse_index"
8
+
9
+ module Upkeep
10
+ module Subscriptions
11
+ class PersistentReverseIndex
12
+ LOOKUP_COLUMNS = [
13
+ :subscription_id,
14
+ :lookup_key_digest,
15
+ :dependency_source,
16
+ :lookup_table,
17
+ :lookup_record_id_snapshot,
18
+ :lookup_attribute,
19
+ :dependency_table,
20
+ :dependency_predicate_digest,
21
+ :dependency_metadata_snapshot,
22
+ :owner_ids_snapshot
23
+ ].freeze
24
+
25
+ def initialize(reverse_index:, index_record:)
26
+ @reverse_index = reverse_index
27
+ @index_record = index_record
28
+ end
29
+
30
+ def entries_for(changes)
31
+ persistent_entries_for(changes)
32
+ end
33
+
34
+ def summary
35
+ {
36
+ lookup_keys: index_record.distinct.count(:lookup_key_digest),
37
+ entries: index_record.count
38
+ }
39
+ end
40
+
41
+ def self.digest(value)
42
+ Digest::SHA256.hexdigest(JSON.generate(canonical_lookup_value(value)))
43
+ end
44
+
45
+ def self.canonical_lookup_value(value)
46
+ case value
47
+ when Array
48
+ value.map { |item| canonical_lookup_value(item) }
49
+ when Hash
50
+ value.keys.sort_by(&:to_s).map do |key|
51
+ [canonical_lookup_value(key), canonical_lookup_value(value.fetch(key))]
52
+ end
53
+ when Symbol
54
+ ["symbol", value.to_s]
55
+ when String
56
+ ["string", value.encode(Encoding::UTF_8)]
57
+ else
58
+ value
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :reverse_index, :index_record
65
+
66
+ def persistent_entries_for(changes)
67
+ lookup_keys = Array(changes).flat_map { |change| reverse_index.lookup_keys_for_change(change) }.uniq
68
+ lookup_keys_by_digest = Hash.new { |hash, digest| hash[digest] = [] }
69
+ lookup_keys.each do |lookup_key|
70
+ lookup_keys_by_digest[self.class.digest(lookup_key)] << lookup_key
71
+ end
72
+ lookup_key_digests = lookup_keys_by_digest.keys
73
+
74
+ index_record
75
+ .where(lookup_key_digest: lookup_key_digests)
76
+ .pluck(*LOOKUP_COLUMNS)
77
+ .flat_map { |row| entries_for_row(row, lookup_keys_by_digest) }
78
+ .uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
79
+ end
80
+
81
+ def entries_for_row(row, lookup_keys_by_digest)
82
+ attributes = LOOKUP_COLUMNS.zip(row).to_h
83
+ lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
84
+ return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
85
+
86
+ dependency = dependency_for_row(attributes)
87
+ dependency_cache_key = dependency.cache_key
88
+ JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
89
+ ReverseIndex::Entry.new(
90
+ attributes.fetch(:subscription_id),
91
+ owner_id,
92
+ dependency_cache_key,
93
+ dependency,
94
+ nil,
95
+ nil
96
+ )
97
+ end
98
+ end
99
+
100
+ def lookup_key_matches_row?(lookup_key, attributes)
101
+ case lookup_key.fetch(0)
102
+ when :active_record_attribute
103
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
104
+ lookup_key.fetch(2) == JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)) &&
105
+ lookup_key.fetch(3).to_s == attributes.fetch(:lookup_attribute).to_s
106
+ when :active_record_attribute_any_id
107
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
108
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
109
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
110
+ when :active_record_collection_column
111
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
112
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
113
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
114
+ else
115
+ false
116
+ end
117
+ end
118
+
119
+ def dependency_for_row(attributes)
120
+ source = attributes.fetch(:dependency_source).to_sym
121
+ case source
122
+ when :active_record_attribute
123
+ Dependencies::ActiveRecordAttribute.new(
124
+ table: attributes.fetch(:dependency_table),
125
+ id: attributes[:lookup_record_id_snapshot] && JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)),
126
+ attribute: attributes.fetch(:lookup_attribute)
127
+ )
128
+ when :active_record_collection, :active_record_query
129
+ metadata = JsonSnapshot.load(attributes.fetch(:dependency_metadata_snapshot))
130
+ dependency_class = source == :active_record_query ? Dependencies::ActiveRecordQuery : Dependencies::ActiveRecordCollection
131
+ dependency_class.new(
132
+ primary_table: attributes.fetch(:dependency_table),
133
+ table_columns: metadata.fetch(:table_columns),
134
+ coverage: metadata.fetch(:coverage),
135
+ sql: metadata.fetch(:sql),
136
+ predicates: metadata.fetch(:predicates)
137
+ )
138
+ else
139
+ raise ArgumentError, "unsupported persistent dependency source: #{source.inspect}"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shape"
4
+
5
+ module Upkeep
6
+ module Subscriptions
7
+ class Registrar
8
+ Registration = Data.define(:identity, :decision, :subscription, :shape)
9
+
10
+ def initialize(store:, shape_cache: ShapeCache.new)
11
+ @store = store
12
+ @shape_cache = shape_cache
13
+ end
14
+
15
+ def register(identity:, decision:, recorder:, metadata: {}, signature: nil)
16
+ shape = shape_cache.resolve(recorder: recorder, decision: decision, signature: signature)
17
+ subscription = store.register(
18
+ subscriber_id: identity.subscriber_id,
19
+ recorder: recorder,
20
+ metadata: metadata.merge(
21
+ shared_stream_names: shape.shared_stream_names,
22
+ subscription_shape_key: shape.key,
23
+ subscription_shape_cache: shape.cache_state
24
+ ).compact,
25
+ entries: shape.entries
26
+ )
27
+
28
+ Registration.new(identity, decision, subscription, shape)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :store, :shape_cache
34
+ end
35
+ end
36
+ end