convert_sdk 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The single persistence port every manager flows through.
5
+ #
6
+ # +DataStoreManager+ wraps a duck-typed *store* (anything responding to
7
+ # +#get(key)+ / +#set(key, value)+) and is the ONLY object that holds a raw
8
+ # store reference — managers (config caching in Story 2.7, sticky bucketing in
9
+ # 2.11, goal dedup in 4.3) never touch a store directly. This gives the SDK
10
+ # one place to enforce three guarantees:
11
+ #
12
+ # 1. *Validation at wiring time.* The supplied store is duck-type-checked once,
13
+ # at construction. A non-conforming store is rejected with a logged error
14
+ # and replaced by a {Stores::MemoryStore} — wiring NEVER raises and NEVER
15
+ # accepts a broken store. (The JS SDK's +isValidDataStore+ checks only that
16
+ # +get+/+set+ are functions, with no arity enforcement; this port matches
17
+ # that contract exactly. Unlike JS — which leaves its data store undefined
18
+ # on invalid input — this Ruby port intentionally falls back to a working
19
+ # MemoryStore, because a Ruby process must never crash on SDK wiring errors.)
20
+ #
21
+ # 2. *Never-crash passthrough.* {#get} / {#set} rescue +StandardError+ from a
22
+ # user-supplied store and log it; a raising store degrades to +nil+ (get) or
23
+ # a no-op (set) instead of crashing the host.
24
+ #
25
+ # 3. *Atomic visitor-data merge.* {#merge_visitor_data} runs the whole
26
+ # read-modify-write cycle inside a manager-level mutex, so a compound
27
+ # "read current state, decide, write" operation is atomic by construction.
28
+ # Goal dedup (Story 4.3) builds its check-then-mark on this guarantee.
29
+ #
30
+ # == One store, two tenants
31
+ #
32
+ # A single store instance backs both config caching and visitor data. Keys are
33
+ # namespaced so the two never collide: config entries use
34
+ # +convert_sdk.config.{sdk_key}+ ({#config_key}) and visitor entries use
35
+ # +{account_id}-{project_id}-{visitor_id}+ ({#visitor_key}, byte-identical to
36
+ # the JS +getStoreKey+ format). The two key shapes are structurally disjoint.
37
+ #
38
+ # == StoreData
39
+ #
40
+ # Visitor data is a string-keyed hash of the JS +StoreData+ shape —
41
+ # +{"bucketing" => {...}, "segments" => {...}, "goals" => {...}}+ (plus
42
+ # +"locations"+). Everything stored is string-keyed (wire-world); no symbols
43
+ # appear in stored structures.
44
+ #
45
+ # == Thread safety
46
+ #
47
+ # The merge cycle is guarded by +@merge_mutex+. The default {Stores::MemoryStore}
48
+ # adds its own internal lock, so in-process merges are atomic. For external
49
+ # stores (e.g. +RedisStore+, Story 2.2) the same code path runs, but
50
+ # cross-process merge atomicity is store-dependent and must be provided by the
51
+ # backing store.
52
+ class DataStoreManager
53
+ # Methods a store must respond to (JS +isValidDataStore+ contract — presence
54
+ # only, no arity check).
55
+ REQUIRED_STORE_METHODS = %i[get set].freeze
56
+
57
+ # @return [Object] the validated backing store (the supplied store, or a
58
+ # {Stores::MemoryStore} fallback).
59
+ attr_reader :store
60
+
61
+ # @param store [Object, nil] a duck-typed store responding to +get+/+set+.
62
+ # +nil+ or an invalid store falls back to a new {Stores::MemoryStore}.
63
+ # @param log_manager [LogManager] injected logger for validation/passthrough
64
+ # diagnostics.
65
+ def initialize(log_manager:, store: nil)
66
+ @log_manager = log_manager
67
+ @store = resolve_store(store)
68
+ # Thread safety: guarded by @merge_mutex.
69
+ @merge_mutex = Thread::Mutex.new
70
+ end
71
+
72
+ # Read the value stored under +key+. A raising store is contained: the error
73
+ # is logged and +nil+ is returned.
74
+ #
75
+ # @param key [String]
76
+ # @return [Object, nil]
77
+ def get(key)
78
+ @store.get(key)
79
+ rescue StandardError => e
80
+ @log_manager.error("DataStoreManager#get: store raised (#{e.message})")
81
+ nil
82
+ end
83
+
84
+ # Store +value+ under +key+. A raising store is contained: the error is
85
+ # logged and the call is a no-op.
86
+ #
87
+ # @param key [String]
88
+ # @param value [Object]
89
+ # @return [void]
90
+ def set(key, value)
91
+ @store.set(key, value)
92
+ nil
93
+ rescue StandardError => e
94
+ @log_manager.error("DataStoreManager#set: store raised (#{e.message})")
95
+ nil
96
+ end
97
+
98
+ # Build the visitor-data store key — byte-identical to the JS
99
+ # +getStoreKey+ format +`${accountId}-${projectId}-${visitorId}`+. This is
100
+ # the SINGLE construction site for visitor keys.
101
+ #
102
+ # @param account_id [String]
103
+ # @param project_id [String]
104
+ # @param visitor_id [String]
105
+ # @return [String]
106
+ def visitor_key(account_id, project_id, visitor_id)
107
+ "#{account_id}-#{project_id}-#{visitor_id}"
108
+ end
109
+
110
+ # Build the config-cache store key. SINGLE construction site for config keys.
111
+ #
112
+ # @param sdk_key [String]
113
+ # @return [String]
114
+ def config_key(sdk_key)
115
+ "convert_sdk.config.#{sdk_key}"
116
+ end
117
+
118
+ # Atomically read-modify-write a visitor's +StoreData+.
119
+ #
120
+ # The entire cycle — read current data, yield it to the block, deep-merge
121
+ # the block's returned partial, write the result — runs inside
122
+ # +@merge_mutex+, so it is atomic by construction. The block receives the
123
+ # current stored data (or +{}+ for a first write) and returns a +StoreData+
124
+ # partial to merge in; this lets a caller inspect current state and decide
125
+ # what to write atomically (the substrate for Story 4.3's check-then-mark
126
+ # goal dedup).
127
+ #
128
+ # Merge semantics match the JS +objectDeepMerge+: nested string-keyed hashes
129
+ # merge recursively, arrays union (deduped, new values first), and scalars
130
+ # from the partial win.
131
+ #
132
+ # @param account_id [String]
133
+ # @param project_id [String]
134
+ # @param visitor_id [String]
135
+ # @yieldparam current [Hash] the current stored +StoreData+ (or +{}+).
136
+ # @yieldreturn [Hash] the +StoreData+ partial to merge in.
137
+ # @return [Hash] the merged, persisted +StoreData+.
138
+ def merge_visitor_data(account_id, project_id, visitor_id)
139
+ key = visitor_key(account_id, project_id, visitor_id)
140
+ @merge_mutex.synchronize do
141
+ current = get(key) || {}
142
+ partial = yield(current)
143
+ merged = deep_merge(current, partial || {})
144
+ set(key, merged)
145
+ merged
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ # Validate and resolve the backing store. Invalid → logged error + a fresh
152
+ # MemoryStore fallback.
153
+ def resolve_store(store)
154
+ return Stores::MemoryStore.new if store.nil?
155
+
156
+ if valid_store?(store)
157
+ store
158
+ else
159
+ @log_manager.error("DataStoreManager#resolve_store: rejected store " \
160
+ "#{store.class} (must respond to get/set); using MemoryStore")
161
+ Stores::MemoryStore.new
162
+ end
163
+ end
164
+
165
+ # JS +isValidDataStore+ parity: presence of +get+ and +set+, no arity check.
166
+ def valid_store?(store)
167
+ REQUIRED_STORE_METHODS.all? { |m| store.respond_to?(m) }
168
+ end
169
+
170
+ # Recursive deep merge mirroring the JS +objectDeepMerge+ contract. Arrays
171
+ # union (new values first, deduped); nested hashes recurse; scalars from the
172
+ # right-hand (new) value win.
173
+ def deep_merge(base, incoming)
174
+ base.merge(incoming) do |_key, base_val, new_val|
175
+ if base_val.is_a?(Array) && new_val.is_a?(Array)
176
+ (new_val + base_val).uniq
177
+ elsif base_val.is_a?(Hash) && new_val.is_a?(Hash)
178
+ deep_merge(base_val, new_val)
179
+ else
180
+ new_val
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sentinel"
4
+
5
+ module ConvertSdk
6
+ # Bucketing business misses, signaled as frozen singleton {Sentinel}s.
7
+ #
8
+ # Wire strings are byte-identical to the JS SDK
9
+ # (javascript-sdk/packages/enums/src/bucketing-error.ts). NOTE: the JS source
10
+ # has a typo in the constant *name* (+VARIAION_NOT_DECIDED+, missing the "T").
11
+ # The Ruby constant spelling is CORRECTED to {VARIATION_NOT_DECIDED}; the wire
12
+ # string +convert.com_variation_not_decided+ is left byte-identical to JS.
13
+ module BucketingError
14
+ # No variation could be decided for the visitor.
15
+ # Wire: +convert.com_variation_not_decided+ (byte-identical to JS).
16
+ VARIATION_NOT_DECIDED = Sentinel.new("convert.com_variation_not_decided")
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Feature toggle status. Wire values byte-identical to the JS SDK
5
+ # (javascript-sdk/packages/enums/src/feature-status.ts).
6
+ module FeatureStatus
7
+ # The feature is on. Wire: +enabled+.
8
+ ENABLED = "enabled"
9
+
10
+ # The feature is off. Wire: +disabled+.
11
+ DISABLED = "disabled"
12
+ end
13
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Recognized keys for conversion goal data (consumed by conversion tracking,
5
+ # Story 4.3). Wire strings byte-identical to the JS SDK
6
+ # (javascript-sdk/packages/enums/src/goal-data-key.ts).
7
+ module GoalDataKey
8
+ # Revenue amount. Wire: +amount+.
9
+ AMOUNT = "amount"
10
+ # Number of products. Wire: +productsCount+.
11
+ PRODUCTS_COUNT = "productsCount"
12
+ # Transaction identifier. Wire: +transactionId+.
13
+ TRANSACTION_ID = "transactionId"
14
+ # Custom dimension 1. Wire: +customDimension1+.
15
+ CUSTOM_DIMENSION_1 = "customDimension1"
16
+ # Custom dimension 2. Wire: +customDimension2+.
17
+ CUSTOM_DIMENSION_2 = "customDimension2"
18
+ # Custom dimension 3. Wire: +customDimension3+.
19
+ CUSTOM_DIMENSION_3 = "customDimension3"
20
+ # Custom dimension 4. Wire: +customDimension4+.
21
+ CUSTOM_DIMENSION_4 = "customDimension4"
22
+ # Custom dimension 5. Wire: +customDimension5+.
23
+ CUSTOM_DIMENSION_5 = "customDimension5"
24
+
25
+ # All recognized goal-data keys, in declaration order. Frozen array for
26
+ # validation use (Story 4.3).
27
+ ALL = [
28
+ AMOUNT, PRODUCTS_COUNT, TRANSACTION_ID,
29
+ CUSTOM_DIMENSION_1, CUSTOM_DIMENSION_2, CUSTOM_DIMENSION_3,
30
+ CUSTOM_DIMENSION_4, CUSTOM_DIMENSION_5
31
+ ].freeze
32
+
33
+ # The two-worlds mapping (Story 4.3): the PUBLIC Ruby +track_conversion+
34
+ # +goal_data:+ surface accepts snake_case symbol keys; this is the SINGLE
35
+ # place the snake_case input is translated to the camelCase WIRE identifier.
36
+ # The conversion build site (DataManager#convert) consults this map to
37
+ # validate caller keys and emit the wire-correct +[{key, value}]+ pairs;
38
+ # any key absent here is unknown and rejected. Frozen so it cannot drift.
39
+ RUBY_KEY_MAP = {
40
+ amount: AMOUNT,
41
+ products_count: PRODUCTS_COUNT,
42
+ transaction_id: TRANSACTION_ID,
43
+ custom_dimension_1: CUSTOM_DIMENSION_1,
44
+ custom_dimension_2: CUSTOM_DIMENSION_2,
45
+ custom_dimension_3: CUSTOM_DIMENSION_3,
46
+ custom_dimension_4: CUSTOM_DIMENSION_4,
47
+ custom_dimension_5: CUSTOM_DIMENSION_5
48
+ }.freeze
49
+
50
+ # Translate a single caller-supplied +goal_data+ key (symbol or string,
51
+ # snake_case OR the camelCase wire form) to its wire identifier, or +nil+
52
+ # when the key is not one of the eight platform keys (caller rejects it).
53
+ # Accepting the wire form too keeps the surface forgiving for integrators
54
+ # who already know the platform identifiers.
55
+ #
56
+ # @param key [Symbol, String] the caller key.
57
+ # @return [String, nil] the wire identifier, or nil when unrecognized.
58
+ def self.wire_key_for(key)
59
+ RUBY_KEY_MAP[key.to_sym] || (ALL.include?(key.to_s) ? key.to_s : nil)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Logging verbosity levels, ordered least-to-most severe. Integer values are
5
+ # JS-parity, verified against javascript-sdk/packages/enums/src/log-level.ts.
6
+ # Consumed by LogManager (Story 1.4): a message logs when its level is >= the
7
+ # configured threshold; +SILENT+ suppresses everything.
8
+ module LogLevel
9
+ # Finest-grained tracing.
10
+ TRACE = 0
11
+ # Debug diagnostics.
12
+ DEBUG = 1
13
+ # Informational messages.
14
+ INFO = 2
15
+ # Warnings.
16
+ WARN = 3
17
+ # Errors.
18
+ ERROR = 4
19
+ # Suppress all logging.
20
+ SILENT = 5
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sentinel"
4
+
5
+ module ConvertSdk
6
+ # Rule-evaluation business misses, signaled as frozen singleton {Sentinel}s.
7
+ #
8
+ # Returned by audience/rule evaluation when a decision cannot be made. Wire
9
+ # strings are byte-identical to the JS SDK
10
+ # (javascript-sdk/packages/enums/src/rule-error.ts) and appear on the wire.
11
+ module RuleError
12
+ # No data was found to evaluate the rule. Wire: +convert.com_no_data_found+.
13
+ NO_DATA_FOUND = Sentinel.new("convert.com_no_data_found")
14
+
15
+ # More data is required before a decision can be made.
16
+ # Wire: +convert.com_need_more_data+.
17
+ NEED_MORE_DATA = Sentinel.new("convert.com_need_more_data")
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # SDK system event names, fired by EventManager across Epics 2-4. Wire strings
5
+ # are byte-identical to the JS SDK
6
+ # (javascript-sdk/packages/enums/src/system-events.ts).
7
+ module SystemEvents
8
+ # SDK is ready. Wire: +ready+.
9
+ READY = "ready"
10
+ # Remote config was updated. Wire: +config.updated+.
11
+ CONFIG_UPDATED = "config.updated"
12
+ # A bucketing decision was made. Wire: +bucketing+.
13
+ BUCKETING = "bucketing"
14
+ # A conversion was tracked. Wire: +conversion+.
15
+ CONVERSION = "conversion"
16
+ # The API request queue was released. Wire: +api.queue.released+.
17
+ API_QUEUE_RELEASED = "api.queue.released"
18
+ # Visitor segments were computed. Wire: +segments+.
19
+ SEGMENTS = "segments"
20
+ # A location was activated. Wire: +location.activated+.
21
+ LOCATION_ACTIVATED = "location.activated"
22
+ # A location was deactivated. Wire: +location.deactivated+.
23
+ LOCATION_DEACTIVATED = "location.deactivated"
24
+ # Audiences were evaluated. Wire: +audiences+.
25
+ AUDIENCES = "audiences"
26
+ # The datastore queue was released. Wire: +datastore.queue.released+.
27
+ DATASTORE_QUEUE_RELEASED = "datastore.queue.released"
28
+ end
29
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Synchronous, thread-safe pub/sub engine for SDK lifecycle events.
5
+ #
6
+ # +EventManager+ is the single emission point for the SDK's lifecycle signals.
7
+ # Consumers subscribe with {#on} using the cross-SDK-consistent event names
8
+ # ({SystemEvents}); the SDK's internal stages fire those events with {#fire}
9
+ # as wiring lands in later stories (Client +ready+ in 2.5, +config.updated+
10
+ # per refresh in 2.7, +bucketing+ in 2.11/4.1, +conversion+ in 4.3,
11
+ # +api.queue.released+ in 4.2). This story delivers the engine only.
12
+ #
13
+ # == Event names are a wire-parity surface (FR57)
14
+ #
15
+ # Event names are byte-identical to the JS SDK's +SystemEvents+ strings. A
16
+ # {SystemEvents} constant *is* its wire string (e.g.
17
+ # +SystemEvents::READY == "ready"+), so +on(SystemEvents::READY)+ and
18
+ # +on("ready")+ register under the SAME string key. Names are normalized to
19
+ # their string form (+#to_s+) before they touch the registry.
20
+ #
21
+ # == Synchronous firing
22
+ #
23
+ # Events fire synchronously, in registration order, at each lifecycle stage —
24
+ # no event thread, no queue. A slow listener slows the SDK (documented, JS
25
+ # parity). The firing path never raises into its caller: a listener that
26
+ # raises is caught and logged, and the remaining listeners still run.
27
+ #
28
+ # == Deferred replay for late subscribers
29
+ #
30
+ # Some events (READY, CONVERSION in JS) fire with <tt>deferred: true</tt>. The
31
+ # first deferred emission of an event records its +{payload, err}+ so a
32
+ # listener that subscribes *after* the event already happened is replayed the
33
+ # stored value the moment it registers. This lets late subscribers observe a
34
+ # one-shot lifecycle signal they would otherwise have missed.
35
+ #
36
+ # == Thread safety
37
+ #
38
+ # The listener registry and the deferred store are both guarded by
39
+ # +@listeners_mutex+. Registration mutates the registry inside the lock.
40
+ # Firing takes a +dup+ snapshot of the listener list inside the lock, then
41
+ # iterates that snapshot OUTSIDE the lock — so a listener body (which runs
42
+ # unlocked) may itself call {#on} to register a new listener without
43
+ # deadlocking. The newly added listener is not invoked by the in-flight fire
44
+ # (it was not in the snapshot); it participates in subsequent fires.
45
+ class EventManager
46
+ # @param log_manager [LogManager] sink for contained listener failures and
47
+ # unknown-event debug traces.
48
+ def initialize(log_manager:)
49
+ @log_manager = log_manager
50
+ # event name (String) => Array<Proc> of listeners, registration-ordered.
51
+ @listeners = {}
52
+ # event name (String) => { payload:, err: } recorded by the first
53
+ # deferred fire, replayed to late subscribers.
54
+ @deferred = {}
55
+ # Thread safety: guarded by @listeners_mutex (both @listeners and @deferred).
56
+ @listeners_mutex = Thread::Mutex.new
57
+ end
58
+
59
+ # Subscribe to an event. Public API.
60
+ #
61
+ # Accepts a {SystemEvents} constant (which IS its string value) or any
62
+ # matching string; the name is normalized to its string form so both
63
+ # spellings register under one key. If the event was previously fired with
64
+ # <tt>deferred: true</tt>, the listener is invoked immediately with the
65
+ # stored payload/err (deferred replay).
66
+ #
67
+ # @param event [String] a {SystemEvents} value or matching string.
68
+ # @yieldparam payload [Object, nil] the emitted payload.
69
+ # @yieldparam err [Object, nil] the emitted error, or +nil+ on normal
70
+ # emission. Single-parameter blocks work — extra args are ignored.
71
+ # @return [self]
72
+ def on(event, &listener)
73
+ return self if listener.nil?
74
+
75
+ key = event.to_s
76
+ deferred = @listeners_mutex.synchronize do
77
+ (@listeners[key] ||= []) << listener
78
+ @deferred[key]
79
+ end
80
+ # Replay outside the lock so the listener body may itself call #on.
81
+ invoke(key, listener, deferred[:payload], deferred[:err]) if deferred
82
+ self
83
+ end
84
+
85
+ # Emit an event to all currently registered listeners. Internal API.
86
+ #
87
+ # @api private
88
+ # @param event [String] a {SystemEvents} value or matching string.
89
+ # @param payload [Object, nil] delivered as the listener's first argument.
90
+ # @param err [Object, nil] delivered as the listener's second argument
91
+ # (+nil+ on normal emission).
92
+ # @param deferred [Boolean] when true, the first such emission of this event
93
+ # is recorded for replay to late subscribers (see class docs).
94
+ # @return [void]
95
+ def fire(event, payload = nil, err = nil, deferred: false)
96
+ key = event.to_s
97
+ snapshot = @listeners_mutex.synchronize do
98
+ @deferred[key] ||= { payload: payload, err: err } if deferred
99
+ @listeners[key]&.dup
100
+ end
101
+
102
+ if snapshot.nil? || snapshot.empty?
103
+ @log_manager.debug("EventManager#fire: no listeners for '#{key}'")
104
+ return
105
+ end
106
+
107
+ # Iterate the snapshot OUTSIDE the lock — listener bodies run unlocked and
108
+ # may re-register without deadlock.
109
+ snapshot.each { |listener| invoke(key, listener, payload, err) }
110
+ end
111
+
112
+ private
113
+
114
+ # Invoke one listener with exception containment. A raising listener is
115
+ # caught (StandardError only — never Exception) and logged at error level;
116
+ # it is never re-raised, so siblings still fire and the host never crashes.
117
+ def invoke(event, listener, payload, err)
118
+ listener.call(payload, err)
119
+ rescue StandardError => e
120
+ @log_manager.error(
121
+ "EventManager#fire: listener for '#{event}' raised #{e.class}: #{e.message}"
122
+ )
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Variation-selection support — the thin per-experience / across-experiences
5
+ # entry surface over the {DataManager} decision flow.
6
+ #
7
+ # This mirrors the JS +experience-manager.ts+ division of labor EXACTLY: the
8
+ # ExperienceManager owns variation SELECTION (the public-ish +select_variation+
9
+ # / +select_variations+ seams that {Context} drives), while the ORDERED decision
10
+ # FLOW — entity -> archived -> environment -> stored-bucketing -> locations ->
11
+ # audiences -> custom segments -> traffic allocation -> variation — lives in
12
+ # {DataManager#get_bucketing} (JS +data-manager.ts:227-720+). A reordered step
13
+ # is a parity bug; the order is owned in ONE place (DataManager) and exercised,
14
+ # not duplicated here.
15
+ #
16
+ # == +select_variation+ (one experience by key)
17
+ #
18
+ # Delegates straight to {DataManager#get_bucketing}, returning a frozen
19
+ # {BucketedVariation} on a hit or a {Sentinel} ({RuleError}/{BucketingError}) on
20
+ # a miss — JS +selectVariation+ (+experience-manager.ts:110-116+).
21
+ #
22
+ # == +select_variations+ (all experiences)
23
+ #
24
+ # Maps {DataManager#get_bucketing} over EVERY configured experience and FILTERS
25
+ # OUT every non-decision: +nil+, {RuleError}, and {BucketingError} sentinels.
26
+ # This is the JS +selectVariations+ contract (+experience-manager.ts:159-168+):
27
+ # the across-all-experiences call returns ONLY the variations a visitor was
28
+ # actually bucketed into; misses never appear in the list (FR16 return shape).
29
+ #
30
+ # @api private
31
+ class ExperienceManager
32
+ # @param data_manager [DataManager] the decision-flow owner (holds the config
33
+ # snapshot, the bucketing/rule collaborators, and the visitor store seam).
34
+ # @param log_manager [LogManager, nil] optional debug logger.
35
+ def initialize(data_manager:, log_manager: nil)
36
+ @data_manager = data_manager
37
+ @log_manager = log_manager
38
+ end
39
+
40
+ # Decide one experience for a visitor by experience key.
41
+ #
42
+ # @param visitor_id [String] the visitor identifier.
43
+ # @param experience_key [String] the experience +key+ to decide.
44
+ # @param attributes [Hash] bucketing attributes — +:visitor_properties+
45
+ # (audiences), +:location_properties+ (locations/site_area), +:environment+,
46
+ # +:update_visitor_properties+.
47
+ # @return [BucketedVariation, Sentinel] a frozen variation, or a
48
+ # {RuleError}/{BucketingError} sentinel on a miss.
49
+ def select_variation(visitor_id, experience_key, attributes = {})
50
+ @data_manager.get_bucketing(visitor_id, experience_key, attributes)
51
+ end
52
+
53
+ # Decide ALL configured experiences for a visitor, returning only the
54
+ # successful bucketed variations (misses filtered — JS parity).
55
+ #
56
+ # @param visitor_id [String] the visitor identifier.
57
+ # @param attributes [Hash] bucketing attributes (see {#select_variation}).
58
+ # @return [Array<BucketedVariation>] the frozen variations the visitor was
59
+ # bucketed into (sentinels and nils excluded).
60
+ def select_variations(visitor_id, attributes = {})
61
+ @data_manager.experiences.filter_map do |experience|
62
+ next unless experience.is_a?(Hash)
63
+
64
+ result = @data_manager.get_bucketing(visitor_id, experience["key"], attributes)
65
+ result if result.is_a?(BucketedVariation)
66
+ end
67
+ end
68
+ end
69
+ end