convert_sdk 2.0.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4971b19e554964f05a8c86555b919d102492a09190eac1747567177f375dc29e
4
- data.tar.gz: b4a91c4ab52075f0268aa1f138989f406d4f9166f7cd06f859ea879a4f7e2975
3
+ metadata.gz: 01f414eebe47b8979033ef84e6d1f6c8c36147fce4075ca95258ddd3f8158e75
4
+ data.tar.gz: '01794d5e51f41191f2402fc0327e6d979f140b9fd105db70b140fb277dc3e513'
5
5
  SHA512:
6
- metadata.gz: 288df65beff1f18b3dbe25334e31e852222705c0786113cb56fbd3a253ffc5edcfba335a00258c062ea7faf34728afd5e26ecd234f7df305cb74e043275e962b
7
- data.tar.gz: 2ee04c45261064677c2cd2da5bd4a89e06747f39cf172a9d02a87a976ef167d48b7ddafaa5df3b1c1ad9961e5e7fced4d636313f9645dd5ac2b0bf670f9c748e
6
+ metadata.gz: ce9300fe59f844d33189e40c6c9532a9f71bcff6f73ab087d41839a9572b543ddfc1a1ebdfabf9bc868a29ab810ad80a88672f1ca923bc2f4c8aa221727826af
7
+ data.tar.gz: b2e48b3667a3f4597af73c09e143b1f6912561cadacd4aba6ca24f11092ad85493c7f24fd948221da356e1ec0e36285a8d8e2762f9aafdce726137c2602cd55c
data/RELEASE.md CHANGED
@@ -202,13 +202,15 @@ Releases are fully automatic. The process:
202
202
 
203
203
  semantic-release checks the current branch against the `branches` entry in
204
204
  `release.config.mjs` (currently `['main']`). On `main`, the dry-run prints the
205
- next-version plan. On any other branch it exits with:
205
+ next-version plan. On any other branch it stops with:
206
206
 
207
207
  ```
208
- This test run was not triggered in a known release branch
208
+ This test run was triggered on the branch <your-branch>, while semantic-release
209
+ is configured to only publish from main, therefore a new version won’t be published.
209
210
  ```
210
211
 
211
- That message is **expected** — it confirms the config parses. To exercise a full
212
+ That message is **expected** — it confirms the config parses and every plugin in
213
+ the chain loaded. To exercise a full
212
214
  dry-run on a feature branch, temporarily add the branch name to
213
215
  `release.config.mjs`'s `branches` array, run the dry-run, then discard the
214
216
  temporary edit before committing:
@@ -308,6 +310,6 @@ through:
308
310
  | Release ran but published nothing | No release-worthy commit since the last tag (only `chore`/`docs`/`ci`/`test`/`style`/`perf`). | Expected — semantic-release succeeds silently with no version. Land a `feat:`/`fix:` to publish. |
309
311
  | `gem push` failed / no RubyGems credential | The RubyGems Trusted Publisher is not registered (or the repo/workflow filename in the registration doesn't match `convertcom/ruby-sdk` ↔ `release.yml`). | Re-check the trusted-publisher entry on rubygems.org (One-Time Setup step 1). The workflow needs `id-token: write` (it has it) and the OIDC exchange step must run before semantic-release. |
310
312
  | GitHub Release/tag created but gem missing | Should not happen — publish runs before the Release (publish-before-Release). If you see it, a manual tag was likely pushed out of band. | Do not hand-create `v*` tags. Let the pipeline own tagging. |
311
- | `yarn release:dry-run` errors "This test run was not triggered in a known release branch" | Expected on any branch except `main`. | To force a full dry-run on a feature branch, temporarily add the branch to `release.config.mjs`'s `branches` array (discard before committing). On `main`, this means the local branch isn't pushed to `origin` — push first. |
313
+ | `yarn release:dry-run` stops with "This test run was triggered on the branch …, while semantic-release is configured to only publish from `main`" | Expected on any branch except `main`. | To force a full dry-run on a feature branch, temporarily add the branch to `release.config.mjs`'s `branches` array (discard before committing). On `main`, this means the local branch isn't pushed to `origin` — push first. |
312
314
  | `Cannot find module '<preset>'` from a semantic-release plugin | The yarn node linker isn't producing a `node_modules/` tree the dynamic preset import can walk. | Confirm `.yarnrc.yml` selects the `node-modules` linker and re-run `yarn install --immutable`. |
313
315
  | Forbidden release mechanism reintroduced (lint job fails) | A `@semantic-release/git`/`@semantic-release/changelog` plugin, a `rake release` task, `bundler/gem_tasks`, or `rubygems/release-gem` was added. | These are blocked by the release-safety step in the `Lint (RuboCop)` job. Remove the forbidden mechanism — publishing happens only via OIDC `release.yml`. |
@@ -46,6 +46,29 @@ module ConvertSdk
46
46
  # (+nil+ for lookups, +self+ for the chainable mutator). A raising collaborator
47
47
  # degrades the call; it never crashes the host request.
48
48
  class Context
49
+ # The reserved keys the public per-call hash accepts (CAP-3): a +:merged_map+ row
50
+ # lands in the engine envelope at +destination+, a +:raw_per_call+ row in that reader.
51
+ RESERVED_KEYS = {
52
+ "location_properties" => { source: :merged_map, destination: :location_properties,
53
+ scope: "every decision entry point" },
54
+ "environment" => { source: :merged_map, destination: :environment,
55
+ scope: "every decision entry point" },
56
+ "enable_tracking" => { source: :raw_per_call, destination: :tracking_enabled_for_call,
57
+ scope: "honoured on run_experience(s), accepted inert on run_feature(s)" },
58
+ "experience_keys" => { source: :raw_per_call, destination: :experiences,
59
+ scope: "run_feature(s) only; narrows the experiences decided (CAP-1)" },
60
+ "type_casting" => { source: :raw_per_call, destination: :type_casting,
61
+ scope: "run_feature(s) only; false returns the config-stored variables (CAP-2)" },
62
+ "ruleData" => { source: :raw_per_call, destination: :visitor_properties,
63
+ scope: "run_custom_segments only; camelCase on a snake_case surface (SD-4)" }
64
+ }.freeze
65
+
66
+ # Engine-readable keys the seam never lifts, each with its reason (CAP-3's negative list).
67
+ NOT_LIFTED = {
68
+ "enable_storage" => "preview owns the persistence gate (D-4)",
69
+ "update_visitor_properties" => "documented Ruby divergence (D-5)"
70
+ }.freeze
71
+
49
72
  # @param visitor_id [String] the resolved visitor id (validated non-blank by
50
73
  # {Client#create_context} before construction).
51
74
  # @param attributes [Hash, nil] the per-visitor attributes; deep-stringified
@@ -357,9 +380,12 @@ module ConvertSdk
357
380
  # {SystemEvents::BUCKETING} event, exactly as {#run_experience}'s forced
358
381
  # branch returns before {#fire_bucketing}. Every OTHER decided variation is
359
382
  # zero-trace like {#run_experience}'s other-experience branch:
360
- # {#decision_attributes} suppresses the sticky persist and the tracking
361
- # verdict is forced +false+, while the {SystemEvents::BUCKETING} event still
362
- # fires per variation (see {#run_experience}'s doc for the Ruby/JS divergence).
383
+ # {#decision_attributes} suppresses the sticky persist, the tracking verdict
384
+ # is forced +false+ so the outbound enqueue is skipped, and
385
+ # {#fire_bucketing}'s +@preview.nil?+ guard suppresses the
386
+ # {SystemEvents::BUCKETING} event as well — NO event fires for ANY
387
+ # experience on a preview-active context (JS parity — +context.ts:260+:
388
+ # +if (!this._preview) { fire BUCKETING }+).
363
389
  #
364
390
  # @param attributes [Hash, nil] optional per-call visitor properties merged
365
391
  # over the context attributes (deep-stringified). May carry +:enable_tracking+.
@@ -402,23 +428,30 @@ module ConvertSdk
402
428
  # render_legacy_checkout
403
429
  # end
404
430
  #
405
- # NOTE (accepted parity break): JS +runFeature+ accepts an optional
406
- # +experienceKeys+ filter argument; this Ruby surface intentionally OMITS it
407
- # (deferred feature). Resolution always spans all configured experiences.
431
+ # A per-call +experience_keys+ Array narrows which experiences are decided,
432
+ # and so which sticky assignments the read commits (CAP-1); absent, nil or
433
+ # empty decides every configured experience (D-6).
434
+ # A per-call +type_casting+ of +false+ returns variables as config stores them (CAP-2).
435
+ #
436
+ # NOTE (accepted parity break): +type_casting: nil+ leaves casting ON here,
437
+ # where the JS presence-based rule would disable it (D-8).
408
438
  #
409
439
  # Never raises into the host: an internal failure degrades to a DISABLED
410
440
  # {BucketedFeature} (carrying the requested key) + an +error+ log (NFR9).
411
441
  #
412
442
  # @param key [String] the feature +key+ to evaluate.
413
- # @param attributes [Hash, nil] optional per-call visitor properties merged
414
- # over the context attributes (deep-stringified).
443
+ # @param attributes [Hash, nil] optional per-call visitor properties merged over
444
+ # the context attributes (deep-stringified). May carry +:experience_keys+
445
+ # (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
415
446
  # @return [BucketedFeature, Array<BucketedFeature>] the resolved feature(s).
416
447
  def run_feature(key, attributes = nil)
417
448
  manager = @feature_manager
418
449
  return disabled_feature(key) if manager.nil?
419
450
 
420
451
  @data_manager.ensure_fresh_config!
421
- manager.run_feature(@visitor_id, key, decision_attributes(attributes))
452
+ manager.run_feature(@visitor_id, key, decision_attributes(attributes),
453
+ experiences: experience_keys_for_call(attributes),
454
+ type_casting: type_casting_for_call?(attributes))
422
455
  rescue StandardError => e
423
456
  @log_manager.error("Context#run_feature: #{e.class}: #{e.message}")
424
457
  disabled_feature(key)
@@ -437,15 +470,18 @@ module ConvertSdk
437
470
  # Never raises into the host: an internal failure degrades to +[]+ + an
438
471
  # +error+ log (NFR9).
439
472
  #
440
- # @param attributes [Hash, nil] optional per-call visitor properties merged
441
- # over the context attributes (deep-stringified).
473
+ # @param attributes [Hash, nil] optional per-call visitor properties merged over
474
+ # the context attributes (deep-stringified). May carry +:experience_keys+
475
+ # (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
442
476
  # @return [Array<BucketedFeature>] the resolved features (enabled + disabled).
443
477
  def run_features(attributes = nil)
444
478
  manager = @feature_manager
445
479
  return [] if manager.nil?
446
480
 
447
481
  @data_manager.ensure_fresh_config!
448
- manager.run_features(@visitor_id, decision_attributes(attributes))
482
+ manager.run_features(@visitor_id, decision_attributes(attributes),
483
+ experiences: experience_keys_for_call(attributes),
484
+ type_casting: type_casting_for_call?(attributes))
449
485
  rescue StandardError => e
450
486
  @log_manager.error("Context#run_features: #{e.class}: #{e.message}")
451
487
  []
@@ -623,11 +659,16 @@ module ConvertSdk
623
659
 
624
660
  # {#run_experiences}'s preview branch, extracted to keep #run_experiences
625
661
  # within RuboCop's ABC/complexity budget: drop the previewed experience's
626
- # normally-decided entry (so it fires no BUCKETING event for the overridden
627
- # decision), fire every OTHER experience's event with tracking suppressed,
628
- # then append the forced variation. A defensive Sentinel from
629
- # {#forced_preview_variation} (set_preview pre-validates, so not expected)
630
- # appends nothing.
662
+ # normally-decided entry (the forced decision replaces it), route every
663
+ # OTHER experience through {#fire_bucketing} with +track: false+, then
664
+ # append the forced variation. On a preview-active context that
665
+ # {#fire_bucketing} call is inert BY DESIGN — its +@preview.nil?+ guard
666
+ # suppresses the {SystemEvents::BUCKETING} event and +track: false+
667
+ # suppresses the enqueue, so all it emits is the +debug+ suppression line.
668
+ # The seam is kept rather than skipped so the zero-trace verdict stays
669
+ # enforced at the SINGLE bucketing site instead of being duplicated here.
670
+ # A defensive Sentinel from {#forced_preview_variation} (set_preview
671
+ # pre-validates, so not expected) appends nothing.
631
672
  def force_preview_in_run_all(variations, preview)
632
673
  others = variations.reject { |variation| variation.experience_key == preview[:experience_key] }
633
674
  others.each { |variation| fire_bucketing(variation.experience_key, variation, track: false) }
@@ -706,7 +747,8 @@ module ConvertSdk
706
747
  # per-call +ruleData+ (and context attributes) win over stored segments. All
707
748
  # deep-stringified to string keys (the rule engine reads string keys).
708
749
  def visitor_properties(attributes)
709
- rule_data = attributes.is_a?(Hash) ? (attributes[:ruleData] || attributes["ruleData"]) : nil
750
+ key = reserved_key_name(:visitor_properties)&.to_s
751
+ rule_data = key && attributes.is_a?(Hash) ? attributes[key.to_sym] || attributes[key] : nil
710
752
  empty = {} #: Hash[String, untyped]
711
753
  merged = @attributes.merge(deep_stringify(rule_data || empty))
712
754
  stored = get_visitor_data["segments"]
@@ -738,12 +780,43 @@ module ConvertSdk
738
780
  # byte-identical to the pre-qs-03 behavior.
739
781
  def decision_attributes(per_call)
740
782
  merged = @attributes.merge(deep_stringify(per_call || {}))
741
- {
742
- visitor_properties: merged,
743
- location_properties: merged["location_properties"],
744
- environment: merged["environment"],
745
- enable_storage: @preview.nil?
746
- }
783
+ envelope = { visitor_properties: merged } #: Hash[Symbol, untyped]
784
+ reserved_rows(:merged_map).each { |name, fields| envelope[fields[:destination]] = merged[name.to_s] }
785
+ envelope[:enable_storage] = @preview.nil?
786
+ envelope
787
+ end
788
+
789
+ def reserved_rows(source)
790
+ RESERVED_KEYS.reject { |name, fields| NOT_LIFTED.key?(name.to_s) || fields[:source] != source }
791
+ end
792
+
793
+ # The per-call key name the enumeration routes to +destination+, nil when none does.
794
+ def reserved_key_name(destination)
795
+ row = reserved_rows(:raw_per_call).find { |_, fields| fields[:destination] == destination }
796
+ row&.first
797
+ end
798
+
799
+ # The per-call experience-key filter (CAP-1). A non-Array value degrades to
800
+ # no filter with a +warn+ (SD-2); nil is absence and never warns.
801
+ def experience_keys_for_call(attributes)
802
+ key = reserved_key_name(:experiences)&.to_s
803
+ return nil unless key && attributes.is_a?(Hash)
804
+
805
+ value = attributes.fetch(key.to_sym) { attributes.fetch(key, nil) }
806
+ return value if value.nil? || value.is_a?(Array)
807
+
808
+ @log_manager.warn("Context#run_feature: #{key} must be an Array, got #{value.class} — ignoring it")
809
+ nil
810
+ end
811
+
812
+ # The per-call casting switch (CAP-2): only an explicit +false+ turns it off (D-8).
813
+ def type_casting_for_call?(attributes)
814
+ return true unless attributes.is_a?(Hash)
815
+
816
+ key = reserved_key_name(:type_casting)&.to_s
817
+ return true if key.nil?
818
+
819
+ attributes.fetch(key.to_sym) { attributes.fetch(key, true) } != false
747
820
  end
748
821
 
749
822
  # The single named seam fired once per fresh/decided variation. It does TWO
@@ -809,7 +882,10 @@ module ConvertSdk
809
882
  def tracking_enabled_for_call?(attributes)
810
883
  return true unless attributes.is_a?(Hash)
811
884
 
812
- value = attributes.fetch(:enable_tracking) { attributes.fetch("enable_tracking", true) }
885
+ key = reserved_key_name(:tracking_enabled_for_call)&.to_s
886
+ return true if key.nil?
887
+
888
+ value = attributes.fetch(key.to_sym) { attributes.fetch(key, true) }
813
889
  value != false
814
890
  end
815
891
 
@@ -84,16 +84,20 @@ module ConvertSdk
84
84
  # @param feature_key [String] the feature +key+ to resolve.
85
85
  # @param attributes [Hash] bucketing attributes (+:visitor_properties+,
86
86
  # +:location_properties+, +:environment+) — see {DataManager#get_bucketing}.
87
+ # @param experiences [Array<String>, nil] optional experience-key filter
88
+ # narrowing which experiences are decided (CAP-1); nil/empty means all.
89
+ # @param type_casting [Boolean] +false+ returns variables as config stores them (CAP-2).
87
90
  # @return [BucketedFeature, Array<BucketedFeature>] enabled feature(s) or a
88
91
  # frozen DISABLED {BucketedFeature} on a miss.
89
- def run_feature(visitor_id, feature_key, attributes = {})
92
+ def run_feature(visitor_id, feature_key, attributes = {}, experiences: nil, type_casting: true)
90
93
  declared = @data_manager.feature_by_key(feature_key)
91
94
  unless declared
92
95
  @log_manager&.debug("FeatureManager#run_feature: feature not declared key=#{feature_key}")
93
96
  return disabled_feature(key: feature_key)
94
97
  end
95
98
 
96
- enabled = run_features(visitor_id, attributes, features: [feature_key])
99
+ enabled = run_features(visitor_id, attributes,
100
+ experiences: experiences, features: [feature_key], type_casting: type_casting)
97
101
  if enabled.empty?
98
102
  @log_manager&.debug("FeatureManager#run_feature: not bucketed into a carrying variation key=#{feature_key}")
99
103
  return disabled_from_declared(declared)
@@ -119,12 +123,13 @@ module ConvertSdk
119
123
  # @param experiences [Array<String>, nil] optional experience-key filter.
120
124
  # @param features [Array<String>, nil] optional feature-key filter (suppresses
121
125
  # the DISABLED padding).
126
+ # @param type_casting [Boolean] +false+ skips the per-variable conversion (CAP-2).
122
127
  # @return [Array<BucketedFeature>] the resolved features.
123
- def run_features(visitor_id, attributes = {}, experiences: nil, features: nil)
128
+ def run_features(visitor_id, attributes = {}, experiences: nil, features: nil, type_casting: true)
124
129
  declared_by_id = features_by_id
125
130
  variations = bucketed_variations(visitor_id, attributes, experiences)
126
131
 
127
- bucketed = collect_enabled(variations, declared_by_id, features)
132
+ bucketed = collect_enabled(variations, declared_by_id, features, type_casting: type_casting)
128
133
 
129
134
  # Pad with DISABLED features ONLY when no feature filter is supplied.
130
135
  append_disabled(bucketed, declared_by_id) if features.nil?
@@ -179,11 +184,12 @@ module ConvertSdk
179
184
  # Walk every bucketed variation's +fullStackFeature+ changes, mapping each to
180
185
  # its declared feature (by id), casting the variables, and building an ENABLED
181
186
  # {BucketedFeature}. Honours the optional +feature_keys+ filter.
182
- def collect_enabled(variations, declared_by_id, feature_keys)
187
+ def collect_enabled(variations, declared_by_id, feature_keys, type_casting: true)
183
188
  bucketed = [] #: Array[BucketedFeature]
184
189
  variations.each do |variation|
185
190
  feature_changes(variation).each do |change|
186
- feature = enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
191
+ feature = enabled_feature_from_change(variation, change, declared_by_id, feature_keys,
192
+ type_casting: type_casting)
187
193
  bucketed << feature if feature
188
194
  end
189
195
  end
@@ -208,13 +214,14 @@ module ConvertSdk
208
214
 
209
215
  # Build the ENABLED {BucketedFeature} for one feature change, or nil when the
210
216
  # change has no feature_id, the feature is undeclared, or it is filtered out.
211
- def enabled_feature_from_change(variation, change, declared_by_id, feature_keys)
217
+ def enabled_feature_from_change(variation, change, declared_by_id, feature_keys, type_casting: true)
212
218
  data = change["data"]
213
219
  declared = declared_for_change(data, declared_by_id)
214
220
  return nil if declared.nil?
215
221
  return nil if filtered_out?(declared, feature_keys)
216
222
 
217
- build_enabled(variation, declared, cast_variables(declared, data["variables_data"]))
223
+ variables = cast_variables(declared, data["variables_data"], type_casting: type_casting)
224
+ build_enabled(variation, declared, variables)
218
225
  end
219
226
 
220
227
  # The declared feature a feature-change maps to (by data.feature_id), or nil
@@ -239,7 +246,8 @@ module ConvertSdk
239
246
  # Cast every supplied raw variable per its declared type (data-driven). A
240
247
  # variable with no declared type passes through uncast (JS warns
241
248
  # FEATURE_VARIABLES_TYPE_NOT_FOUND). Returns a fresh string-keyed Hash.
242
- def cast_variables(declared, raw)
249
+ # +type_casting+ +false+ skips the conversion only, not the walk or the warn (CAP-2, D-8).
250
+ def cast_variables(declared, raw, type_casting: true)
243
251
  unless raw.is_a?(Hash)
244
252
  @log_manager&.warn("FeatureManager#run_features: feature variables not found")
245
253
  return {}
@@ -250,7 +258,7 @@ module ConvertSdk
250
258
  raw.each do |name, value|
251
259
  type = variable_type(definitions, name)
252
260
  if type
253
- cast[name.to_s] = cast_type(value, type)
261
+ cast[name.to_s] = type_casting ? cast_type(value, type) : value
254
262
  else
255
263
  @log_manager&.warn("FeatureManager#run_features: variable type not found name=#{name}")
256
264
  cast[name.to_s] = value
@@ -10,5 +10,5 @@ module ConvertSdk
10
10
  # derives its version from this run's git tag, not from this file (FR66).
11
11
  # Mirrors the Android SDK's `0.0.0` placeholder in gradle/libs.versions.toml.
12
12
  # @return [String]
13
- VERSION = "2.0.0"
13
+ VERSION = "2.1.0"
14
14
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: convert_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Convert Insights, Inc.