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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The audience/segmentation rule walk — OR -> AND -> OR_WHEN — ported EXACTLY
5
+ # from the JS SDK +packages/rules/src/rule-manager.ts+. JS is the only truth
6
+ # here; the PHP port is quarantined (it diverges on the existence operators and
7
+ # on +isIn+ case-folding — see {Comparisons}).
8
+ #
9
+ # Structure & short-circuit order (mirrors rule-manager.ts:117-255):
10
+ # * Top OR (+isRuleMatched+): for each AND-group, +match = process_and+;
11
+ # return +true+ on the first matching group; after the loop return +match+
12
+ # unless it is +false+ (so a {RuleError} sentinel propagates out). A missing
13
+ # or empty +OR+ logs +RULE_NOT_VALID+ at warn and returns +false+.
14
+ # * AND (+process_and+): for each OR_WHEN leaf-list, +match = process_or_when+;
15
+ # return +match+ on the FIRST non-+true+ result (a +false+ or a sentinel
16
+ # short-circuits the AND). All +true+ -> +true+. Missing/empty +AND+ ->
17
+ # +RULE_NOT_VALID+ warn + +false+.
18
+ # * OR_WHEN (+process_or_when+): for each leaf rule, +match = process_rule_item+;
19
+ # return +true+ on the first match; after the loop return +match+ unless it
20
+ # is +false+. Missing/empty +OR_WHEN+ -> +RULE_NOT_VALID+ warn + +false+.
21
+ #
22
+ # FR22 safety invariant: EVERY empty/missing block shape returns +false+ with a
23
+ # +RULE_NOT_VALID+ warn — an empty audience excludes everyone, it never matches
24
+ # everyone.
25
+ #
26
+ # The undefined-fallback (rule-manager.ts:323-333): when the data key for a leaf
27
+ # is ABSENT and the operator is +exists+/+doesNotExist+, the operator is invoked
28
+ # against {Comparisons::UNDEFINED} (JS +undefined+) so existence semantics hold
29
+ # for absent keys. Ruby models key-absence with the {Comparisons::UNDEFINED}
30
+ # marker because a plain +nil+ means "present null value" (JS +null+), which is
31
+ # a different existence outcome.
32
+ #
33
+ # RuleError propagation: a leaf that returns a {RuleError} sentinel propagates
34
+ # it up the walk via the non-true / non-false short-circuits above (Story 2.11
35
+ # maps the surfaced sentinel to the public return).
36
+ #
37
+ # Pure in-memory (NFR1). Rule options (+keys_case_sensitive+, +negation+) come
38
+ # from the injected {Config}, never inline. Valid-rule outcomes log at debug;
39
+ # invalid/empty shapes log +RULE_NOT_VALID+ at warn (rule-manager.ts levels).
40
+ #
41
+ # @api private
42
+ class RuleManager
43
+ # The +RULE_NOT_VALID+ message — byte-identical to the JS SDK dictionary
44
+ # (+packages/enums/src/dictionary.ts:12+: "Provided rule is not valid"). Logged
45
+ # at warn for every empty/missing/invalid block, the FR22 exclusion signal.
46
+ RULE_NOT_VALID = "Provided rule is not valid"
47
+
48
+ # Build a rule walker bound to a {Config}'s rule options and a comparison
49
+ # processor.
50
+ #
51
+ # @param config [Config] supplies +keys_case_sensitive+ and +negation+.
52
+ # @param comparisons [#dispatch] the operator processor (defaults to
53
+ # {Comparisons}); must expose +dispatch+ (wire-name => method symbol) and
54
+ # respond to each mapped method as +(value, test_against, negation)+.
55
+ # @param log_manager [LogManager, nil] optional logger; warn for invalid
56
+ # shapes, debug for valid-rule outcomes.
57
+ def initialize(config:, comparisons: Comparisons, log_manager: nil)
58
+ @keys_case_sensitive = config.keys_case_sensitive
59
+ @comparisons = comparisons
60
+ @log_manager = log_manager
61
+ end
62
+
63
+ # Walk a rule set against a data hash. The top OR level.
64
+ #
65
+ # @param data [Hash{String=>Object}] the key-value data to match.
66
+ # @param rule_set [Hash] the OR/AND/OR_WHEN rule structure.
67
+ # @param log_entry [String, nil] an optional label for the entity being matched.
68
+ # @return [Boolean, Sentinel] true/false, or a {RuleError} sentinel propagated
69
+ # from a leaf.
70
+ def is_rule_matched(data, rule_set, log_entry = nil)
71
+ or_groups = nonempty_block(rule_set, "OR")
72
+ unless or_groups
73
+ warn_rule_not_valid("RuleManager#is_rule_matched", log_entry)
74
+ return false
75
+ end
76
+
77
+ match = false
78
+ or_groups.each do |and_group|
79
+ match = process_and(data, and_group)
80
+ return true if match == true
81
+
82
+ log_outcome("RuleManager#is_rule_matched", match, log_entry)
83
+ end
84
+ return match if match != false
85
+
86
+ false
87
+ end
88
+
89
+ private
90
+
91
+ # AND block: every OR_WHEN leaf-list must return true. Returns the first
92
+ # non-true result (false or a sentinel short-circuits). rule-manager.ts:191-220.
93
+ def process_and(data, and_group)
94
+ leaves = nonempty_block(and_group, "AND")
95
+ unless leaves
96
+ warn_rule_not_valid("RuleManager#process_and")
97
+ return false
98
+ end
99
+
100
+ leaves.each do |or_when|
101
+ match = process_or_when(data, or_when)
102
+ return match if match != true
103
+ end
104
+ @log_manager&.debug("RuleManager#process_and: AND block matched")
105
+ true
106
+ end
107
+
108
+ # OR_WHEN block: the first matching leaf wins. After the loop, returns the
109
+ # last result unless false (so a sentinel propagates). rule-manager.ts:229-255.
110
+ def process_or_when(data, or_when)
111
+ leaves = nonempty_block(or_when, "OR_WHEN")
112
+ unless leaves
113
+ warn_rule_not_valid("RuleManager#process_or_when")
114
+ return false
115
+ end
116
+
117
+ match = false
118
+ leaves.each do |rule|
119
+ match = process_rule_item(data, rule)
120
+ return true if match == true
121
+ end
122
+ return match if match != false
123
+
124
+ false
125
+ end
126
+
127
+ # A single leaf rule. Validates shape, resolves the operator from the
128
+ # comparison processor's dispatch map, and evaluates it against the matching
129
+ # data value — or against {Comparisons::UNDEFINED} for an absent key under an
130
+ # existence operator. rule-manager.ts:264-365.
131
+ def process_rule_item(data, rule)
132
+ unless valid_rule?(rule)
133
+ warn_rule_not_valid("RuleManager#process_rule_item")
134
+ return false
135
+ end
136
+
137
+ negation = rule["matching"]["negated"] || false
138
+ match_type = rule["matching"]["match_type"]
139
+ method = @comparisons.dispatch[match_type]
140
+ unless method
141
+ @log_manager&.warn(
142
+ "RuleManager#process_rule_item: rule matching type #{match_type.inspect} is not supported"
143
+ )
144
+ return false
145
+ end
146
+
147
+ evaluate_leaf(data, rule, match_type, method, negation)
148
+ end
149
+
150
+ # Resolve the data value for the leaf's key and invoke the operator. Iterates
151
+ # the data keys honoring +keys_case_sensitive+; on a key match invokes the
152
+ # operator with the data value. On no key match, an existence operator is
153
+ # invoked against the UNDEFINED marker (undefined-fallback). Otherwise false.
154
+ def evaluate_leaf(data, rule, match_type, method, negation)
155
+ data_value = lookup_data_value(data, rule["key"])
156
+ unless data_value.equal?(Comparisons::UNDEFINED)
157
+ result = @comparisons.public_send(method, data_value, rule["value"], negation)
158
+ debug_outcome("key matched", match_type, result)
159
+ return result
160
+ end
161
+
162
+ # Key absent: only the existence operators fall back to the UNDEFINED
163
+ # marker (rule-manager.ts:323-333); everything else is a no-match.
164
+ if existence_operator?(match_type)
165
+ result = @comparisons.public_send(method, Comparisons::UNDEFINED, rule["value"], negation)
166
+ debug_outcome("existence-fallback", match_type, result)
167
+ return result
168
+ end
169
+
170
+ @log_manager&.debug("RuleManager#evaluate_leaf: key #{rule["key"].inspect} not found in data")
171
+ false
172
+ end
173
+
174
+ # Resolve the data value for a rule key honoring +keys_case_sensitive+.
175
+ # Returns {Comparisons::UNDEFINED} when the key is ABSENT — distinct from a
176
+ # present +nil+ value (JS null), which is returned as +nil+.
177
+ def lookup_data_value(data, rule_key)
178
+ return Comparisons::UNDEFINED unless data.is_a?(Hash) && !data.empty?
179
+
180
+ target = @keys_case_sensitive ? rule_key : rule_key.to_s.downcase
181
+ data.each do |key, value|
182
+ k = @keys_case_sensitive ? key : key.to_s.downcase
183
+ return value if k == target
184
+ end
185
+ Comparisons::UNDEFINED
186
+ end
187
+
188
+ # Debug-log a leaf evaluation outcome.
189
+ def debug_outcome(path, match_type, result)
190
+ @log_manager&.debug(
191
+ "RuleManager#evaluate_leaf: #{path} match_type=#{match_type.inspect} result=#{result.inspect}"
192
+ )
193
+ end
194
+
195
+ # JS +isValidRule+ (rule-manager.ts:162-182): requires a +matching+ object
196
+ # with string +match_type+ and boolean +negated+; existence operators are
197
+ # valid without a +value+, all others require a +value+ field.
198
+ def valid_rule?(rule)
199
+ return false unless rule.is_a?(Hash)
200
+
201
+ matching = rule["matching"]
202
+ return false unless matching.is_a?(Hash)
203
+ return false unless matching["match_type"].is_a?(String)
204
+ return false unless [true, false].include?(matching["negated"])
205
+ return true if existence_operator?(matching["match_type"])
206
+
207
+ rule.key?("value")
208
+ end
209
+
210
+ # The existence operators get the undefined-fallback and are value-optional.
211
+ # Mirrors the JS CookieMatchingOptions.EXISTS / DOES_NOT_EXIST special-case.
212
+ def existence_operator?(match_type)
213
+ %w[exists doesNotExist].include?(match_type)
214
+ end
215
+
216
+ # Extract +container[key]+ as a non-empty Array, or +nil+ when the container
217
+ # is not a Hash, the key is missing, the value is not an Array, or the Array
218
+ # is empty. The single source of the FR22 "empty/missing block" predicate —
219
+ # every empty/missing OR/AND/OR_WHEN shape collapses to +nil+ here.
220
+ def nonempty_block(container, key)
221
+ return nil unless container.is_a?(Hash)
222
+
223
+ value = container[key]
224
+ return nil unless value.is_a?(Array) && !value.empty?
225
+
226
+ value
227
+ end
228
+
229
+ # Emit a RULE_NOT_VALID warn (FR22 exclusion signal) — rule-manager.ts warn
230
+ # sites at :148/:214/:249/:359.
231
+ def warn_rule_not_valid(site, log_entry = nil)
232
+ suffix = log_entry ? " (#{log_entry})" : ""
233
+ @log_manager&.warn("#{site}: #{RULE_NOT_VALID}#{suffix}")
234
+ end
235
+
236
+ # Debug-log a valid-rule outcome (match / no-match / sentinel) at the OR level.
237
+ def log_outcome(site, match, log_entry)
238
+ suffix = log_entry ? " (#{log_entry})" : ""
239
+ @log_manager&.debug("#{site}: outcome=#{match.inspect}#{suffix}")
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Visitor segmentation — the REPORTING-data layer that attaches segment ids to a
5
+ # visitor's +StoreData+ in the JS SDK's wire shape (FR28–FR30). Ported from the
6
+ # JS SDK +packages/segments/src/segments-manager.ts+; the PHP reference is
7
+ # QUARANTINED here because it diverges on two wire keys.
8
+ #
9
+ # == PHP-divergence #2 — the wire-key quarantine (FR30)
10
+ #
11
+ # JS +SegmentsKeys+ (+segments-keys.ts:7-15+) emit camelCase:
12
+ # +visitorType+ / +customSegments+. PHP +SegmentsKeys.php:14-15+ emit snake_case:
13
+ # +visitor_type+ / +custom_segments+ — a real, disk-verified divergence. Segment
14
+ # data rides the tracking payload's +segments+ object (Epic 4 +ApiManager+ emits
15
+ # the visitor's stored +segments+ verbatim), so the wrong key would silently
16
+ # mis-filter every Ruby segment-report. Ruby follows JS: the seven report-segment
17
+ # keys, and +customSegments+ in particular, are the ONLY ones persisted, as
18
+ # camelCase STRINGS at rest in +StoreData+. The {SegmentsManager} never produces
19
+ # the PHP variants; Story 3.2's quarantine spec asserts their absence.
20
+ #
21
+ # == The report-segment filter ({#filter_report_segments}, +data-manager.ts:1180-1199+)
22
+ #
23
+ # Exactly seven keys are report-segments: +country+, +browser+, +devices+,
24
+ # +source+, +campaign+, +visitorType+, +customSegments+. {#put_segments} keeps
25
+ # ONLY these; every other key is silently DROPPED (JS routes them to a separate
26
+ # +properties+ bucket the segments layer ignores) — IGNORE, not reject. Ruby adds
27
+ # a +debug+ line naming the dropped keys (an observability addition; JS drops
28
+ # silently). A filter that leaves NOTHING is a no-op write (JS +if (reportSegments)+).
29
+ #
30
+ # == Custom-segment evaluation REUSES the rule engine ({#select_custom_segments})
31
+ #
32
+ # +run_custom_segments+ does NOT introduce new rule logic. For each requested
33
+ # segment key it looks up the {ConfigSegment} entity (DataManager +segments+
34
+ # reader), evaluates that segment's +rules+ against the supplied
35
+ # +segment_rule+ data via {RuleManager#is_rule_matched} (so NEED_MORE_DATA and
36
+ # every operator semantic come for FREE), and — on a match — appends the
37
+ # segment's id to the visitor's stored +customSegments+ list (deduped). A
38
+ # surfaced {RuleError} sentinel propagates out verbatim (mirrors JS
39
+ # +setCustomSegments+'s +Object.values(RuleError).includes(...)+ early-return).
40
+ #
41
+ # == Persistence
42
+ #
43
+ # All writes flow through the {DataStoreManager} atomic visitor-data merge
44
+ # (Story 2.1) into +StoreData["segments"]+. Stored data is string-keyed
45
+ # wire-world by design (so Epic 4's payload builder needs zero translation).
46
+ #
47
+ # @api private
48
+ class SegmentsManager
49
+ # The +customSegments+ wire key — byte-identical to JS
50
+ # +SegmentsKeys.CUSTOM_SEGMENTS+ (+segments-keys.ts:14+). The visitor's matched
51
+ # custom-segment ids live under this key in +StoreData["segments"]+.
52
+ CUSTOM_SEGMENTS = "customSegments"
53
+
54
+ # The full report-segment key set — byte-identical to JS +SegmentsKeys+
55
+ # (+segments-keys.ts:7-15+). The report-segment filter is restricted to exactly
56
+ # these seven; +visitorType+/+customSegments+ are the JS wire keys that diverge
57
+ # from PHP's snake_case variants (FR30). The SINGLE source of the allowed set.
58
+ SEGMENTS_KEYS = %w[
59
+ country browser devices source campaign visitorType customSegments
60
+ ].freeze
61
+
62
+ # @param data_manager [DataManager] the config reader surface (supplies the
63
+ # {ConfigSegment} entities by key and the account/project store-key halves).
64
+ # @param data_store_manager [DataStoreManager] the persistence port (atomic
65
+ # visitor-data merge into +StoreData["segments"]+).
66
+ # @param account_resolver [#call] resolves the account id (store-key half).
67
+ # @param project_resolver [#call] resolves the project id (store-key half).
68
+ # @param rule_manager [RuleManager] the Epic 2 rule walker reused for
69
+ # custom-segment evaluation (never re-implemented here).
70
+ # @param log_manager [LogManager, nil] optional logger; debug on misses/drops,
71
+ # warn on already-present ids.
72
+ def initialize(data_manager:, data_store_manager:, account_resolver:,
73
+ project_resolver:, rule_manager:, log_manager: nil)
74
+ @data_manager = data_manager
75
+ @data_store_manager = data_store_manager
76
+ @account_resolver = account_resolver
77
+ @project_resolver = project_resolver
78
+ @rule_manager = rule_manager
79
+ @log_manager = log_manager
80
+ end
81
+
82
+ # Set default report-segments for a visitor (JS +setDefaultSegments+ ->
83
+ # +putSegments+, +context.ts:434-436+ / +segments-manager.ts:78-85+).
84
+ #
85
+ # The supplied segments are passed through {#filter_report_segments} (only the
86
+ # seven {SEGMENTS_KEYS} survive); a non-empty result is merged into the
87
+ # visitor's +StoreData["segments"]+ via the atomic store merge. An all-dropped
88
+ # input is a no-op (JS +if (reportSegments)+).
89
+ #
90
+ # @param visitor_id [String]
91
+ # @param segments [Hash] the candidate report-segments (string-keyed wire shape).
92
+ # @return [void]
93
+ def put_segments(visitor_id, segments)
94
+ report_segments = filter_report_segments(segments)
95
+ return if report_segments.empty?
96
+
97
+ merge_segments(visitor_id, report_segments)
98
+ nil
99
+ end
100
+
101
+ # Evaluate the named custom segments for a visitor and attach matching ids
102
+ # (JS +selectCustomSegments+ -> +setCustomSegments+, +segments-manager.ts:153-185+).
103
+ #
104
+ # Each requested key is resolved to a {ConfigSegment} via the DataManager
105
+ # +segments+ reader; the segment's +rules+ are walked against +segment_rule+
106
+ # by {RuleManager#is_rule_matched}. A surfaced {RuleError} sentinel propagates
107
+ # out verbatim (no attachment). Matching segment ids are appended to the
108
+ # visitor's stored +customSegments+ list (deduped); an unknown key is skipped
109
+ # with a debug log.
110
+ #
111
+ # @param visitor_id [String]
112
+ # @param segment_keys [Array<String>] the segment keys to evaluate.
113
+ # @param segment_rule [Hash, nil] the visitor data the segment rules match
114
+ # against; +nil+ attaches every resolved segment unconditionally (JS:
115
+ # +if (!segmentRule || segmentsMatched)+).
116
+ # @return [Hash, Sentinel, nil] the updated segments hash, a propagated
117
+ # {RuleError}, or +nil+ when nothing matched.
118
+ def select_custom_segments(visitor_id, segment_keys, segment_rule = nil)
119
+ segments = lookup_segments(segment_keys)
120
+ set_custom_segments(visitor_id, segments, segment_rule)
121
+ end
122
+
123
+ private
124
+
125
+ # Keep ONLY the seven {SEGMENTS_KEYS} report-segments; silently drop the rest
126
+ # (JS +filterReportSegments+, +data-manager.ts:1180-1199+ — non-segment keys go
127
+ # to a +properties+ bucket the segments layer ignores). Dropped keys are named
128
+ # in a debug line (Ruby observability addition). Returns a string-keyed hash
129
+ # (possibly empty).
130
+ def filter_report_segments(segments)
131
+ return {} unless segments.is_a?(Hash)
132
+
133
+ kept = {} #: Hash[String, untyped]
134
+ dropped = [] #: Array[String]
135
+ segments.each do |key, value|
136
+ if SEGMENTS_KEYS.include?(key)
137
+ kept[key] = value
138
+ else
139
+ dropped << key
140
+ end
141
+ end
142
+ unless dropped.empty?
143
+ @log_manager&.debug("SegmentsManager#filter_report_segments: dropped non-report keys #{dropped.inspect}")
144
+ end
145
+ kept
146
+ end
147
+
148
+ # Resolve the requested segment keys to {ConfigSegment} entities via the
149
+ # DataManager +segments+ reader (JS +getEntities(keys, 'segments')+). Unknown
150
+ # keys yield no entity and are debug-logged + skipped. Preserves request order.
151
+ def lookup_segments(segment_keys)
152
+ return [] unless segment_keys.is_a?(Array)
153
+
154
+ all = @data_manager.segments
155
+ segment_keys.filter_map do |key|
156
+ entity = all.find { |seg| seg.is_a?(Hash) && seg["key"] == key }
157
+ @log_manager&.debug("SegmentsManager#lookup_segments: no segment found for key=#{key}") unless entity
158
+ entity
159
+ end
160
+ end
161
+
162
+ # The id-attachment core (JS +setCustomSegments+, +segments-manager.ts:87-143+).
163
+ # For each resolved segment: when a rule is supplied and nothing has matched
164
+ # yet, walk the segment's rules — a {RuleError} sentinel short-circuits and
165
+ # propagates out. On a match (or when no rule was supplied) append the segment
166
+ # id unless already stored. A non-empty append is persisted via {#put_segments}.
167
+ def set_custom_segments(visitor_id, segments, segment_rule)
168
+ existing = stored_custom_segments(visitor_id)
169
+ segment_ids = [] #: Array[String]
170
+ matched = false #: (bool | Sentinel)
171
+
172
+ segments.each do |segment|
173
+ if segment_rule && matched != true
174
+ matched = @rule_manager.is_rule_matched(segment_rule, segment["rules"], "ConfigSegment ##{segment["id"]}")
175
+ return matched if matched.is_a?(Sentinel)
176
+ end
177
+
178
+ next unless !segment_rule || matched == true
179
+
180
+ append_segment_id(segment, existing, segment_ids)
181
+ end
182
+
183
+ persist_custom_segments(visitor_id, existing, segment_ids)
184
+ end
185
+
186
+ # Append +segment["id"]+ (stringified) to the pending list unless it is already
187
+ # stored or already pending; an already-stored id warns (JS
188
+ # +CUSTOM_SEGMENTS_KEY_FOUND+).
189
+ def append_segment_id(segment, existing, segment_ids)
190
+ id = segment["id"]&.to_s
191
+ return if id.nil?
192
+
193
+ if existing.include?(id)
194
+ @log_manager&.warn("SegmentsManager#set_custom_segments: custom segment id #{id} already stored")
195
+ elsif !segment_ids.include?(id)
196
+ segment_ids << id
197
+ end
198
+ end
199
+
200
+ # Persist the newly-matched ids (appended to the existing list) into
201
+ # +StoreData["segments"]["customSegments"]+, or no-op + debug when nothing
202
+ # matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or nil.
203
+ def persist_custom_segments(visitor_id, existing, segment_ids)
204
+ if segment_ids.empty?
205
+ @log_manager&.debug("SegmentsManager#set_custom_segments: no segments matched")
206
+ return nil
207
+ end
208
+
209
+ segments_data = { CUSTOM_SEGMENTS => existing + segment_ids }
210
+ put_segments(visitor_id, segments_data)
211
+ segments_data
212
+ end
213
+
214
+ # The visitor's currently-stored +customSegments+ id list (or +[]+), read from
215
+ # +StoreData["segments"]+ through the store seam.
216
+ def stored_custom_segments(visitor_id)
217
+ key = @data_store_manager.visitor_key(account_id, project_id, visitor_id)
218
+ stored = @data_store_manager.get(key)
219
+ segments = stored.is_a?(Hash) ? stored["segments"] : nil
220
+ list = segments.is_a?(Hash) ? segments[CUSTOM_SEGMENTS] : nil
221
+ list.is_a?(Array) ? list : []
222
+ end
223
+
224
+ # Atomically merge a report-segments partial into +StoreData["segments"]+.
225
+ def merge_segments(visitor_id, report_segments)
226
+ @data_store_manager.merge_visitor_data(account_id, project_id, visitor_id) do |_current|
227
+ { "segments" => report_segments }
228
+ end
229
+ end
230
+
231
+ # The account / project halves of the visitor store key (coerced to String —
232
+ # nil-safe before any config is installed).
233
+ def account_id
234
+ @account_resolver.call.to_s
235
+ end
236
+
237
+ def project_id
238
+ @project_resolver.call.to_s
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # A frozen singleton sentinel — the SDK's public contract for a *business miss*.
5
+ #
6
+ # Bucketing and feature decisions that find no answer (no data, not enough
7
+ # data, no variation decided) return a +Sentinel+ singleton rather than raising
8
+ # or returning a bare +nil+. This is architecture Decision 2: misses are
9
+ # signaled by value, never by exception.
10
+ #
11
+ # The protocol a +Sentinel+ implements:
12
+ #
13
+ # * +#to_s+ — the byte-identical JS wire string (appears in payloads/logs).
14
+ # * +#key+ — always +nil+, so the documented integrator pattern
15
+ # +case variation&.key+ falls through to the +else+ branch on a miss.
16
+ # * +#error?+ — always +true+, the value-object discriminator that distinguishes
17
+ # a sentinel from a real {BucketedVariation}/{BucketedFeature} (both +false+).
18
+ #
19
+ # Sentinels are exposed as frozen singleton constants (e.g.
20
+ # {RuleError::NO_DATA_FOUND}), so callers can branch on object identity for
21
+ # granular handling:
22
+ #
23
+ # result = context.run_experience("homepage-test")
24
+ # case result.key
25
+ # when nil
26
+ # # a miss — inspect which one via identity
27
+ # show_default if result.equal?(ConvertSdk::RuleError::NO_DATA_FOUND)
28
+ # else
29
+ # render_variation(result.key)
30
+ # end
31
+ #
32
+ # Equality is intentionally left as default object identity (no +==+ override):
33
+ # two distinct +Sentinel+ instances built from the same wire string are NOT
34
+ # equal, which is why the canonical instances live as shared frozen constants.
35
+ class Sentinel
36
+ # @param wire_string [String] the JS-parity wire string this sentinel emits.
37
+ def initialize(wire_string)
38
+ @wire_string = wire_string.dup.freeze
39
+ freeze
40
+ end
41
+
42
+ # @return [String] the byte-identical JS wire string.
43
+ def to_s
44
+ @wire_string
45
+ end
46
+
47
+ # @return [nil] always nil, so +case variation&.key+ falls through to else.
48
+ def key
49
+ nil
50
+ end
51
+
52
+ # @return [Boolean] always true — a sentinel always signals a business miss.
53
+ def error?
54
+ true
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # Stores backing the SDK's persistence port (sticky bucketing, goal dedup,
5
+ # config caching). Every store duck-types to +#get(key)+ / +#set(key, value)+
6
+ # — the public extension point validated by {DataStoreManager} at wiring time.
7
+ module Stores
8
+ # The default in-process store: a plain +Hash+ guarded by a +Mutex+.
9
+ #
10
+ # +MemoryStore+ is what {DataStoreManager} falls back to when no custom
11
+ # store is supplied (or a supplied store fails validation). It satisfies the
12
+ # duck-typed store contract — +#get+ / +#set+ — with both operations
13
+ # serialized through a single mutex so concurrent reads and writes from
14
+ # multiple threads cannot corrupt the underlying +Hash+ or lose a write.
15
+ #
16
+ # == Per-process limitation
17
+ #
18
+ # State lives only in this process's memory. It is NOT shared across
19
+ # processes, workers, or machines: sticky bucketing (Story 2.11) and goal
20
+ # deduplication (Story 4.3) that round-trip through a +MemoryStore+ are
21
+ # therefore consistent only within the lifetime of a single process. A
22
+ # forked web worker, a restarted process, or a second host each start with
23
+ # an empty store. For cross-process stickiness and dedup, supply a shared
24
+ # backing store — +RedisStore+ (Story 2.2) is the first-party option.
25
+ #
26
+ # == Thread safety
27
+ #
28
+ # All access to the backing +Hash+ is serialized by +@mutex+; there is no
29
+ # public path to the +Hash+ that bypasses the lock.
30
+ class MemoryStore
31
+ def initialize
32
+ # Thread safety: guarded by @mutex.
33
+ @data = {}
34
+ @mutex = Thread::Mutex.new
35
+ end
36
+
37
+ # Read the value stored under +key+.
38
+ #
39
+ # @param key [String] the lookup key.
40
+ # @return [Object, nil] the stored value, or +nil+ if the key is absent.
41
+ def get(key)
42
+ @mutex.synchronize { @data[key] }
43
+ end
44
+
45
+ # Store +value+ under +key+, overwriting any existing value.
46
+ #
47
+ # @param key [String] the storage key.
48
+ # @param value [Object] the value to store.
49
+ # @return [Object] the stored value.
50
+ def set(key, value)
51
+ @mutex.synchronize { @data[key] = value }
52
+ end
53
+ end
54
+ end
55
+ end