upkeep-rails 0.1.6

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -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,129 @@
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
+ persistent_summary = persistent_index.summary
39
+ active_entries = active_index.entries_for(changes)
40
+ active_count = active_index.count
41
+ pending_entries = pending_entries_for(changes)
42
+ pending_count = pending_count_for_payload
43
+ payload[:active_entries] = active_entries.size
44
+ payload[:active_subscriptions] = active_count
45
+ payload[:pending_entries] = pending_entries.size
46
+ payload[:pending_subscriptions] = pending_count
47
+ payload[:persistent_direct_index_entries] = persistent_summary.fetch(:direct).fetch(:entries)
48
+ payload[:persistent_shape_index_entries] = persistent_summary.fetch(:shape).fetch(:entries)
49
+ payload[:persistent_direct_lookup_keys] = persistent_summary.fetch(:direct).fetch(:lookup_keys)
50
+ payload[:persistent_shape_lookup_keys] = persistent_summary.fetch(:shape).fetch(:lookup_keys)
51
+ payload[:persistent_shape_keys] = persistent_summary.fetch(:shape).fetch(:shape_keys)
52
+ payload[:persistent_shape_subscriptions] = persistent_summary.fetch(:shape).fetch(:subscriptions)
53
+
54
+ if active_count.zero?
55
+ persistent_entries = persistent_index.entries_for(changes)
56
+ payload[:mode] = persistent_entries.empty? && pending_entries.any? ? "pending_activation" : "persistent"
57
+ payload[:persistent_entries] = persistent_entries.size
58
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
59
+ return persistent_entries
60
+ end
61
+
62
+ if active_index.covers?(persistent_subscription_count)
63
+ payload[:mode] = "active"
64
+ payload[:persistent_entries] = 0
65
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: [], pending_entries: pending_entries)
66
+ return active_entries
67
+ end
68
+
69
+ persistent_entries = persistent_index.entries_for(changes)
70
+ payload[:mode] = "active_and_persistent"
71
+ payload[:persistent_entries] = persistent_entries.size
72
+ apply_miss_reason(payload, active_entries: active_entries, persistent_entries: persistent_entries, pending_entries: pending_entries)
73
+ merge_entries(active_entries, persistent_entries)
74
+ end
75
+
76
+ def summary
77
+ persistent = persistent_index.summary
78
+ active = active_index.summary
79
+ pending = pending_index&.summary || { lookup_keys: 0, entries: 0, subscriptions: 0 }
80
+ mode = active_index.covers?(persistent_subscription_count) ? :active : :active_and_persistent
81
+ totals = if mode == :active
82
+ active
83
+ else
84
+ {
85
+ lookup_keys: active.fetch(:lookup_keys) + persistent.fetch(:lookup_keys),
86
+ entries: active.fetch(:entries) + persistent.fetch(:entries)
87
+ }
88
+ end
89
+
90
+ {
91
+ lookup_keys: totals.fetch(:lookup_keys),
92
+ entries: totals.fetch(:entries),
93
+ mode: mode,
94
+ active: active,
95
+ pending: pending,
96
+ persistent: persistent
97
+ }
98
+ end
99
+
100
+ private
101
+
102
+ attr_reader :active_index, :persistent_index, :persistent_count, :store, :pending_index
103
+
104
+ def persistent_subscription_count
105
+ persistent_count.call
106
+ end
107
+
108
+ def merge_entries(active_entries, persistent_entries)
109
+ (active_entries + persistent_entries).uniq do |entry|
110
+ [entry.subscription_id, entry.owner_id, entry.dependency_cache_key]
111
+ end
112
+ end
113
+
114
+ def pending_entries_for(changes)
115
+ pending_index ? pending_index.entries_for(changes) : []
116
+ end
117
+
118
+ def pending_count_for_payload
119
+ pending_index&.count || 0
120
+ end
121
+
122
+ def apply_miss_reason(payload, active_entries:, persistent_entries:, pending_entries:)
123
+ return if active_entries.any? || persistent_entries.any?
124
+
125
+ payload[:miss_reason] = pending_entries.any? ? "not_activated_yet" : "no_matching_subscriber"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,223 @@
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
+ SHAPE_LOOKUP_COLUMNS = [
26
+ :subscription_shape_key,
27
+ :lookup_key_digest,
28
+ :dependency_source,
29
+ :lookup_table,
30
+ :lookup_record_id_snapshot,
31
+ :lookup_attribute,
32
+ :dependency_table,
33
+ :dependency_predicate_digest,
34
+ :dependency_metadata_snapshot,
35
+ :owner_ids_snapshot
36
+ ].freeze
37
+
38
+ def initialize(reverse_index:, index_record:, shape_index_record:, subscription_record:)
39
+ @reverse_index = reverse_index
40
+ @index_record = index_record
41
+ @shape_index_record = shape_index_record
42
+ @subscription_record = subscription_record
43
+ end
44
+
45
+ def entries_for(changes)
46
+ persistent_entries_for(changes)
47
+ end
48
+
49
+ def summary
50
+ direct_lookup_key_digests = index_record.distinct.pluck(:lookup_key_digest)
51
+ shape_lookup_key_digests = shape_index_record.distinct.pluck(:lookup_key_digest)
52
+ direct_entries = index_record.count
53
+ shape_entries = shape_index_record.count
54
+ {
55
+ lookup_keys: (direct_lookup_key_digests + shape_lookup_key_digests).uniq.size,
56
+ entries: direct_entries + shape_entries,
57
+ direct: {
58
+ lookup_keys: direct_lookup_key_digests.uniq.size,
59
+ entries: direct_entries
60
+ },
61
+ shape: {
62
+ lookup_keys: shape_lookup_key_digests.uniq.size,
63
+ entries: shape_entries,
64
+ shape_keys: shape_index_record.distinct.count(:subscription_shape_key),
65
+ subscriptions: subscription_record.where.not(subscription_shape_key: nil).count
66
+ }
67
+ }
68
+ end
69
+
70
+ def self.digest(value)
71
+ Digest::SHA256.hexdigest(JSON.generate(canonical_lookup_value(value)))
72
+ end
73
+
74
+ def self.canonical_lookup_value(value)
75
+ case value
76
+ when Array
77
+ value.map { |item| canonical_lookup_value(item) }
78
+ when Hash
79
+ value.keys.sort_by(&:to_s).map do |key|
80
+ [canonical_lookup_value(key), canonical_lookup_value(value.fetch(key))]
81
+ end
82
+ when Symbol
83
+ ["symbol", value.to_s]
84
+ when String
85
+ ["string", value.encode(Encoding::UTF_8)]
86
+ else
87
+ value
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :reverse_index, :index_record, :shape_index_record, :subscription_record
94
+
95
+ def persistent_entries_for(changes)
96
+ lookup_keys = Array(changes).flat_map { |change| reverse_index.lookup_keys_for_change(change) }.uniq
97
+ lookup_keys_by_digest = Hash.new { |hash, digest| hash[digest] = [] }
98
+ lookup_keys.each do |lookup_key|
99
+ lookup_keys_by_digest[self.class.digest(lookup_key)] << lookup_key
100
+ end
101
+ lookup_key_digests = lookup_keys_by_digest.keys
102
+
103
+ direct_entries = index_record
104
+ .where(lookup_key_digest: lookup_key_digests)
105
+ .pluck(*LOOKUP_COLUMNS)
106
+ .flat_map { |row| entries_for_row(row, lookup_keys_by_digest) }
107
+
108
+ shape_entries = shape_entries_for(lookup_key_digests, lookup_keys_by_digest)
109
+
110
+ (direct_entries + shape_entries).uniq { |entry| [entry.subscription_id, entry.owner_id, entry.dependency_cache_key] }
111
+ end
112
+
113
+ def shape_entries_for(lookup_key_digests, lookup_keys_by_digest)
114
+ shape_rows = shape_index_record
115
+ .where(lookup_key_digest: lookup_key_digests)
116
+ .pluck(*SHAPE_LOOKUP_COLUMNS)
117
+ return [] if shape_rows.empty?
118
+
119
+ subscription_ids_by_shape_key = subscription_ids_by_shape_key(
120
+ shape_rows.map { |row| row.fetch(0) }.uniq
121
+ )
122
+ shape_rows.flat_map do |row|
123
+ entries_for_shape_row(row, lookup_keys_by_digest, subscription_ids_by_shape_key)
124
+ end
125
+ end
126
+
127
+ def entries_for_row(row, lookup_keys_by_digest)
128
+ attributes = LOOKUP_COLUMNS.zip(row).to_h
129
+ lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
130
+ return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
131
+
132
+ dependency = dependency_for_row(attributes)
133
+ dependency_cache_key = dependency.cache_key
134
+ JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
135
+ ReverseIndex::Entry.new(
136
+ attributes.fetch(:subscription_id),
137
+ owner_id,
138
+ dependency_cache_key,
139
+ dependency,
140
+ nil,
141
+ nil
142
+ )
143
+ end
144
+ end
145
+
146
+ def entries_for_shape_row(row, lookup_keys_by_digest, subscription_ids_by_shape_key)
147
+ attributes = SHAPE_LOOKUP_COLUMNS.zip(row).to_h
148
+ lookup_keys = lookup_keys_by_digest.fetch(attributes.fetch(:lookup_key_digest)) { return [] }
149
+ return [] unless lookup_keys.any? { |lookup_key| lookup_key_matches_row?(lookup_key, attributes) }
150
+
151
+ dependency = dependency_for_row(attributes)
152
+ dependency_cache_key = dependency.cache_key
153
+ subscription_ids = subscription_ids_by_shape_key.fetch(attributes.fetch(:subscription_shape_key), [])
154
+ subscription_ids.flat_map do |subscription_id|
155
+ JsonSnapshot.load(attributes.fetch(:owner_ids_snapshot)).map do |owner_id|
156
+ ReverseIndex::Entry.new(
157
+ subscription_id,
158
+ owner_id,
159
+ dependency_cache_key,
160
+ dependency,
161
+ nil,
162
+ nil
163
+ )
164
+ end
165
+ end
166
+ end
167
+
168
+ def lookup_key_matches_row?(lookup_key, attributes)
169
+ case lookup_key.fetch(0)
170
+ when :active_record_attribute
171
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
172
+ lookup_key.fetch(2) == JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)) &&
173
+ lookup_key.fetch(3).to_s == attributes.fetch(:lookup_attribute).to_s
174
+ when :active_record_attribute_any_id
175
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
176
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
177
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
178
+ when :active_record_collection_column
179
+ lookup_key.fetch(1).to_s == attributes.fetch(:lookup_table).to_s &&
180
+ attributes.fetch(:lookup_record_id_snapshot).nil? &&
181
+ lookup_key.fetch(2).to_s == attributes.fetch(:lookup_attribute).to_s
182
+ else
183
+ false
184
+ end
185
+ end
186
+
187
+ def dependency_for_row(attributes)
188
+ source = attributes.fetch(:dependency_source).to_sym
189
+ case source
190
+ when :active_record_attribute
191
+ Dependencies::ActiveRecordAttribute.new(
192
+ table: attributes.fetch(:dependency_table),
193
+ id: attributes[:lookup_record_id_snapshot] && JsonSnapshot.load(attributes.fetch(:lookup_record_id_snapshot)),
194
+ attribute: attributes.fetch(:lookup_attribute)
195
+ )
196
+ when :active_record_collection, :active_record_query
197
+ metadata = JsonSnapshot.load(attributes.fetch(:dependency_metadata_snapshot))
198
+ dependency_class = source == :active_record_query ? Dependencies::ActiveRecordQuery : Dependencies::ActiveRecordCollection
199
+ dependency_class.new(
200
+ primary_table: attributes.fetch(:dependency_table),
201
+ table_columns: metadata.fetch(:table_columns),
202
+ coverage: metadata.fetch(:coverage),
203
+ sql: metadata.fetch(:sql),
204
+ predicates: metadata.fetch(:predicates)
205
+ )
206
+ else
207
+ raise ArgumentError, "unsupported persistent dependency source: #{source.inspect}"
208
+ end
209
+ end
210
+
211
+ def subscription_ids_by_shape_key(shape_keys)
212
+ shape_key_lookup = shape_keys.to_h { |shape_key| [shape_key, []] }
213
+ subscription_record
214
+ .where(subscription_shape_key: shape_keys)
215
+ .pluck(:subscription_shape_key, :id)
216
+ .each do |shape_key, id|
217
+ shape_key_lookup[shape_key] << id if shape_key_lookup.key?(shape_key)
218
+ end
219
+ shape_key_lookup.transform_values { |ids| ids.sort_by(&:to_s) }
220
+ end
221
+ end
222
+ end
223
+ 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