convert_sdk 1.0.0 → 2.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.
@@ -86,6 +86,11 @@ module ConvertSdk
86
86
  # Deep-stringify the caller's attributes ONCE at the boundary; internals
87
87
  # only ever see string keys. nil → empty. The caller's hash is never mutated.
88
88
  @attributes = deep_stringify(attributes || {})
89
+ # qs-03 (RB-5) preview state — nil until {#set_preview} succeeds. A plain
90
+ # per-instance ivar (never a class/shared variable): two Contexts NEVER
91
+ # share this (AC7 isolation). Shape: +{experience_id:, variation_id:,
92
+ # experience:, experience_key:}+.
93
+ @preview = nil #: Hash[Symbol, untyped]?
89
94
  end
90
95
 
91
96
  # @return [String] the visitor id this context is bound to.
@@ -105,12 +110,25 @@ module ConvertSdk
105
110
  # +{segments: props}+ — +context.ts:482+). The merge is atomic per visitor:
106
111
  # the read-modify-write runs inside the store manager's merge mutex.
107
112
  #
113
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
114
+ #
115
+ # On a preview-active context ONLY the store write below is skipped — the
116
+ # in-memory +@attributes+ merge always applies (JS parity: JS's public
117
+ # +updateVisitorProperties+ has no preview guard of its own, but the
118
+ # PRIVATE helper it calls to persist DOES skip entirely under preview —
119
+ # +context.ts:626-629+, +if (this._preview) return;+ — leaving the
120
+ # in-memory side unaffected there too). "Per-context scratch" per the
121
+ # qs-03 spec: a later decision on THIS context still sees the merge; no
122
+ # trace of it ever reaches the store.
123
+ #
108
124
  # @param properties [Hash] the properties to merge (symbol or string keys).
109
125
  # @return [self]
110
126
  def update_visitor_properties(properties)
111
127
  normalised = deep_stringify(properties || {})
112
- @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
113
- { "segments" => normalised }
128
+ if @preview.nil?
129
+ @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
130
+ { "segments" => normalised }
131
+ end
114
132
  end
115
133
  @attributes = @attributes.merge(normalised)
116
134
  self
@@ -170,6 +188,79 @@ module ConvertSdk
170
188
  nil
171
189
  end
172
190
 
191
+ # Force a specific variation of an experience for THIS context — bypassing
192
+ # audiences, segments, locations, the environment check, experience status,
193
+ # variation status/traffic filters, stored decisions, and the bucketing
194
+ # hash for that experience only (qs-03 AC4/AC5). Mirrors JS
195
+ # +Context#setPreview+ (+context.ts:143-205+); this Ruby surface is
196
+ # synchronous — the +?exp=+ fallback fetch runs through
197
+ # {ApiManager#get_config_by_experience}, itself process-wide memoized for
198
+ # 60s (qs-03 AC8), so no async/await equivalent is needed here.
199
+ #
200
+ # == Resolution
201
+ #
202
+ # The CURRENT installed config is tried first, by id
203
+ # ({DataManager#experience_by_id}); when absent, a live
204
+ # +?exp={experience_id}+ fetch resolves it instead (never touches the
205
+ # installed config or the store — a previewed experience may be draft/
206
+ # paused and must never be cached alongside production config). The
207
+ # resolved experience is held BY REFERENCE, never duped or rebuilt:
208
+ # DataManager's installed config entities are already deep-frozen (Story
209
+ # 2.7), so simply holding the reference carries none of the JS SDK-7
210
+ # in-place-mutation risk (mutating a frozen Ruby Hash raises
211
+ # +FrozenError+ rather than silently corrupting shared state); a fetched
212
+ # experience is a brand-new object that is never installed anywhere, so it
213
+ # is never shared to begin with.
214
+ #
215
+ # == Inert on bad input (AC7)
216
+ #
217
+ # A blank +experience_id+/+variation_id+, an unresolvable experience
218
+ # (absent from both the installed config and the +?exp=+ fetch response),
219
+ # or an unknown +variation_id+ on the resolved experience all leave preview
220
+ # state UNSET (a +warn+ log, never a raise) — a subsequent
221
+ # {#run_experience} on this context decides exactly as if this method had
222
+ # never been called.
223
+ #
224
+ # == Isolation (AC7)
225
+ #
226
+ # Preview state lives on THIS +Context+ instance only (a plain ivar) — two
227
+ # +Context+s, even for the same client/config, never share it.
228
+ #
229
+ # Never raises into the host: an internal failure degrades to an +error+
230
+ # log + leaves preview state unset (NFR9).
231
+ #
232
+ # @param experience_id [String] the previewed experience's +id+.
233
+ # @param variation_id [String] the variation +id+ to force.
234
+ # @return [self]
235
+ def set_preview(experience_id:, variation_id:)
236
+ return warn_preview_inert("experience_id/variation_id required") if blank?(experience_id) || blank?(variation_id)
237
+
238
+ # Coerce ONCE at this public entry (review round 2) — ids may arrive as
239
+ # different types (an Integer id read straight off a link param, a
240
+ # String elsewhere); every downstream reference (resolution, the
241
+ # decision lookup, the stored @preview hash) threads these coerced
242
+ # locals instead of the original keyword args, mirroring
243
+ # {#fetch_preview_experience}'s existing "ids may arrive as different
244
+ # types" +.to_s+ convention.
245
+ exp_id = experience_id.to_s
246
+ var_id = variation_id.to_s
247
+
248
+ experience = resolve_preview_experience(exp_id)
249
+ return warn_preview_inert("no experience found for id=#{exp_id}", clear: true) if experience.nil?
250
+
251
+ decision = @data_manager.get_preview_decision(experience, var_id)
252
+ return warn_preview_inert("no variation found for id=#{var_id}", clear: true) if decision.nil?
253
+
254
+ @preview = {
255
+ experience_id: exp_id, variation_id: var_id,
256
+ experience: experience, experience_key: experience["key"]
257
+ }
258
+ self
259
+ rescue StandardError => e
260
+ @log_manager.error("Context#set_preview: #{e.class}: #{e.message}")
261
+ self
262
+ end
263
+
173
264
  # Decide a single experience for this visitor and return its variation.
174
265
  #
175
266
  # The optional per-call +attributes+ are deep-stringified and merged OVER the
@@ -197,6 +288,29 @@ module ConvertSdk
197
288
  # NO bucketing event is enqueued (a +debug+ line records the suppression). The
198
289
  # global Config +tracking: false+ switch ALWAYS wins over a per-call +true+.
199
290
  #
291
+ # == Preview forcing (qs-03 AC4/AC5)
292
+ #
293
+ # When {#set_preview} has forced a variation for THIS +key+ on this context,
294
+ # that forced decision is returned DIRECTLY — bypassing decisioning entirely
295
+ # (no audience/location/environment/status/traffic/stored-decision/bucketing
296
+ # walk) and firing NO {SystemEvents::BUCKETING} event (mirrors JS
297
+ # +context.ts:228-235+). Because this check runs FIRST, it naturally takes
298
+ # precedence over a stored decision or fresh bucketing for that experience;
299
+ # every OTHER experience on this same context still decides normally.
300
+ #
301
+ # == Zero-trace on OTHER experiences under preview (qs-03 AC6, RB-6)
302
+ #
303
+ # When THIS context has a preview active but +key+ is NOT the previewed
304
+ # experience, decisioning proceeds NORMALLY — only tracking/persistence are
305
+ # suppressed: {#decision_attributes} threads +enable_storage: false+ so
306
+ # {DataManager#persist_bucketing} never writes sticky StoreData, and the
307
+ # per-call tracking verdict is forced +false+ so {#fire_bucketing}'s
308
+ # {#suppress_bucketing_enqueue?} skips the outbound enqueue.
309
+ # {#fire_bucketing} ALSO suppresses the in-process {SystemEvents::BUCKETING}
310
+ # lifecycle-event fire while a preview is active on this context (JS parity
311
+ # — +context.ts:260+: +if (!this._preview) { fire BUCKETING }+). No event
312
+ # fires for any experience on a preview-active context.
313
+ #
200
314
  # @param key [String] the experience +key+.
201
315
  # @param attributes [Hash, nil] optional per-call visitor properties merged
202
316
  # over the context attributes (deep-stringified). May carry +:enable_tracking+.
@@ -205,9 +319,13 @@ module ConvertSdk
205
319
  manager = @experience_manager
206
320
  return RuleError::NO_DATA_FOUND if manager.nil?
207
321
 
322
+ preview = @preview
323
+ return forced_preview_variation(preview) if preview && key == preview[:experience_key]
324
+
208
325
  @data_manager.ensure_fresh_config!
209
326
  variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes))
210
- fire_bucketing(key, variation, track: tracking_enabled_for_call?(attributes)) unless variation.is_a?(Sentinel)
327
+ track = preview.nil? && tracking_enabled_for_call?(attributes)
328
+ fire_bucketing(key, variation, track: track) unless variation.is_a?(Sentinel)
211
329
  variation
212
330
  rescue StandardError => e
213
331
  @log_manager.error("Context#run_experience: #{e.class}: #{e.message}")
@@ -230,6 +348,19 @@ module ConvertSdk
230
348
  # enqueue for THIS call (decisioning + sticky writes unaffected); the global
231
349
  # Config +tracking: false+ switch always wins (Story 4.5).
232
350
  #
351
+ # On a preview-active context (qs-03 AC6, RB-6) the previewed experience is
352
+ # FORCED to its preview variation here too — run-all forcing is API-agnostic,
353
+ # matching {#run_experience} and the PHP/Android/Python/iOS SDKs. Its
354
+ # normally-decided entry is dropped and the forced variation appended (or
355
+ # appended outright when the previewed experience is absent from the run-all
356
+ # set, e.g. a draft resolved only via +?exp=+); the forced entry fires no
357
+ # {SystemEvents::BUCKETING} event, exactly as {#run_experience}'s forced
358
+ # branch returns before {#fire_bucketing}. Every OTHER decided variation is
359
+ # 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).
363
+ #
233
364
  # @param attributes [Hash, nil] optional per-call visitor properties merged
234
365
  # over the context attributes (deep-stringified). May carry +:enable_tracking+.
235
366
  # @return [Array<BucketedVariation>] the frozen variations (misses excluded).
@@ -239,6 +370,9 @@ module ConvertSdk
239
370
 
240
371
  @data_manager.ensure_fresh_config!
241
372
  variations = manager.select_variations(@visitor_id, decision_attributes(attributes))
373
+ preview = @preview
374
+ return force_preview_in_run_all(variations, preview) if preview
375
+
242
376
  track = tracking_enabled_for_call?(attributes)
243
377
  variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) }
244
378
  variations
@@ -328,6 +462,12 @@ module ConvertSdk
328
462
  # NO lifecycle event fires on segment attachment (JS parity — neither
329
463
  # +setDefaultSegments+ nor +runCustomSegments+ fire +SystemEvents.SEGMENTS+).
330
464
  #
465
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
466
+ #
467
+ # +enable_storage: @preview.nil?+ threads through to
468
+ # {SegmentsManager#put_segments}, suppressing ONLY the persistence write
469
+ # (mirrors JS SDK-6, +context.ts:571+ — +!this._preview+ passed the same way).
470
+ #
331
471
  # Never raises into the host: a failure degrades to an +error+ log and returns
332
472
  # +self+ (NFR9).
333
473
  #
@@ -337,7 +477,7 @@ module ConvertSdk
337
477
  manager = @segments_manager
338
478
  return self if manager.nil?
339
479
 
340
- manager.put_segments(@visitor_id, deep_stringify(segments || {}))
480
+ manager.put_segments(@visitor_id, deep_stringify(segments || {}), enable_storage: @preview.nil?)
341
481
  self
342
482
  rescue StandardError => e
343
483
  @log_manager.error("Context#set_default_segments: #{e.class}: #{e.message}")
@@ -355,6 +495,13 @@ module ConvertSdk
355
495
  #
356
496
  # NO lifecycle event fires on attachment (JS parity, F-014).
357
497
  #
498
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
499
+ #
500
+ # +enable_storage: @preview.nil?+ threads through to
501
+ # {SegmentsManager#select_custom_segments}, suppressing ONLY the persistence
502
+ # write — rule MATCHING still runs exactly as normal (mirrors JS SDK-6,
503
+ # +context.ts:610+ — +!this._preview+ passed the same way).
504
+ #
358
505
  # Never raises into the host: a failure degrades to an +error+ log + +nil+ (NFR9).
359
506
  #
360
507
  # @param segment_keys [Array<String>] the segment keys to evaluate.
@@ -366,7 +513,9 @@ module ConvertSdk
366
513
  manager = @segments_manager
367
514
  return nil if manager.nil?
368
515
 
369
- result = manager.select_custom_segments(@visitor_id, segment_keys, visitor_properties(attributes))
516
+ result = manager.select_custom_segments(
517
+ @visitor_id, segment_keys, visitor_properties(attributes), enable_storage: @preview.nil?
518
+ )
370
519
  result.is_a?(Sentinel) ? result : nil
371
520
  rescue StandardError => e
372
521
  @log_manager.error("Context#run_custom_segments: #{e.class}: #{e.message}")
@@ -408,6 +557,14 @@ module ConvertSdk
408
557
  # @param force_multiple_transactions [Boolean] bypass the per-goal dedup check.
409
558
  # @return [self]
410
559
  def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false)
560
+ # qs-03 (RB-6) — zero-trace: a preview-active context is a FULL no-op here,
561
+ # checked BEFORE the global tracking gate below (and BEFORE
562
+ # DataManager#convert) so NOTHING happens — no enqueue, no CONVERSION
563
+ # event, and no dedup mark (the mark lives inside #convert's atomic
564
+ # dedup-and-mark, never reached). Mirrors JS +context.ts:512-519+
565
+ # (+if (this._preview) return;+ at the top of +trackConversion+).
566
+ return self if @preview
567
+
411
568
  # Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a
412
569
  # suppressed conversion neither enqueues NOR marks dedup (the goals[goalId]
413
570
  # mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal
@@ -433,6 +590,86 @@ module ConvertSdk
433
590
 
434
591
  private
435
592
 
593
+ # {#set_preview}'s inert-path helper: warn-log +detail+ under the
594
+ # +Context#set_preview+ prefix (the single log-message shape every inert
595
+ # branch shares — AC7) and return +self+.
596
+ #
597
+ # +clear:+ mirrors JS +Context#setPreview+ (+context.ts:143-205+) exactly:
598
+ # the BLANK-input guard (this method's caller with +clear: false+, the
599
+ # default) leaves a prior successful +@preview+ untouched — JS's own
600
+ # +if (!experienceId || !variationId)+ branch returns without touching
601
+ # +this._preview+ (+context.ts:146-152+). Every OTHER failure path — an
602
+ # unresolvable experience (+context.ts:172,182+) or an unknown variation id
603
+ # on the resolved experience (+context.ts:195+) — explicitly nulls the
604
+ # preview (JS: +this._preview = null;+ before each of those returns), so a
605
+ # FAILED re-preview after a prior success falls back to normal decisioning
606
+ # rather than stranding the stale forced pick. Callers pass +clear: true+
607
+ # for those two paths only.
608
+ def warn_preview_inert(detail, clear: false)
609
+ @preview = nil if clear
610
+ @log_manager.warn("Context#set_preview: #{detail}")
611
+ self
612
+ end
613
+
614
+ # {#run_experience}'s preview-forcing branch: force-decide THIS visitor's
615
+ # previewed variation via {DataManager#get_preview_decision}. +#set_preview+
616
+ # only ever stores a preview whose (experience, variation_id) pair already
617
+ # resolved a decision, and the resolved experience is a frozen Hash that can
618
+ # never drift afterward, so a nil result here is a defensive fallback, not
619
+ # an expected path.
620
+ def forced_preview_variation(preview)
621
+ @data_manager.get_preview_decision(preview[:experience], preview[:variation_id]) || RuleError::NO_DATA_FOUND
622
+ end
623
+
624
+ # {#run_experiences}'s preview branch, extracted to keep #run_experiences
625
+ # 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.
631
+ def force_preview_in_run_all(variations, preview)
632
+ others = variations.reject { |variation| variation.experience_key == preview[:experience_key] }
633
+ others.each { |variation| fire_bucketing(variation.experience_key, variation, track: false) }
634
+ forced = forced_preview_variation(preview)
635
+ forced.is_a?(Sentinel) ? others : others + [forced]
636
+ end
637
+
638
+ # Resolve the previewed experience for {#set_preview}: the installed
639
+ # config's by-id reader first ({DataManager#experience_by_id}); when
640
+ # absent, the +?exp=+ live-fetch fallback ({#fetch_preview_experience}).
641
+ def resolve_preview_experience(experience_id)
642
+ @data_manager.experience_by_id(experience_id) || fetch_preview_experience(experience_id)
643
+ end
644
+
645
+ # The +?exp={experience_id}+ fallback fetch ({ApiManager#get_config_by_experience},
646
+ # process-wide memoized for 60s — qs-03 AC8) for an experience absent from
647
+ # the installed config (e.g. a draft/paused preview target). A nil
648
+ # {ApiManager} (no Client-wired collaborator) or a failed fetch (the port
649
+ # never raises — it degrades to nil) both resolve to a miss here; the
650
+ # response's own +experiences+ collection is scanned by id (+to_s+
651
+ # compared, ids may arrive as different types).
652
+ def fetch_preview_experience(experience_id)
653
+ manager = @api_manager
654
+ return nil if manager.nil?
655
+
656
+ config = manager.get_config_by_experience(experience_id)
657
+ return nil unless config.is_a?(Hash)
658
+
659
+ experiences = config["experiences"]
660
+ return nil unless experiences.is_a?(Array)
661
+
662
+ target = experience_id.to_s
663
+ experiences.find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == target }
664
+ end
665
+
666
+ # True for +nil+ or a (post-+#strip+) empty String — the blank-input guard
667
+ # for {#set_preview}'s +experience_id+/+variation_id+ (mirrors
668
+ # {Client#create_context}'s blank +visitor_id+ guard).
669
+ def blank?(value)
670
+ value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
671
+ end
672
+
436
673
  # The single conversion seam (mirrors {#fire_bucketing}): enqueue the
437
674
  # wire-shaped event THEN fire the lifecycle event with +deferred: true+ (late
438
675
  # subscribers replay — JS context.ts:416-424). Fired on SUCCESS only (the
@@ -491,12 +728,21 @@ module ConvertSdk
491
728
  # it never defaults location matching to the visitor properties) — supplied
492
729
  # only when the caller passes +location_properties+/+"location_properties"+.
493
730
  # +environment+ is lifted out so the flow's environment-match step sees it.
731
+ #
732
+ # +enable_storage: @preview.nil?+ (qs-03 / RB-6 zero-trace) rides along on
733
+ # EVERY decision built through this ONE seam — {#run_experience},
734
+ # {#run_experiences}, {#run_feature}, and {#run_features} all call it — so a
735
+ # preview-active context's ENTIRE decisioning surface (not just experiences)
736
+ # never persists sticky StoreData ({DataManager#persist_bucketing}'s gate).
737
+ # Absent preview (the overwhelming default), this is always +true+ —
738
+ # byte-identical to the pre-qs-03 behavior.
494
739
  def decision_attributes(per_call)
495
740
  merged = @attributes.merge(deep_stringify(per_call || {}))
496
741
  {
497
742
  visitor_properties: merged,
498
743
  location_properties: merged["location_properties"],
499
- environment: merged["environment"]
744
+ environment: merged["environment"],
745
+ enable_storage: @preview.nil?
500
746
  }
501
747
  end
502
748
 
@@ -514,20 +760,26 @@ module ConvertSdk
514
760
  # wire entry omits the +segments+ key entirely.
515
761
  #
516
762
  # The SOLE bucketing enqueue site. The {SystemEvents::BUCKETING} LIFECYCLE event
517
- # ALWAYS fires (it is decisioning observability, not tracking a host listener
518
- # may need to react to the decision even under consent denial); only the
519
- # outbound ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
763
+ # fires for every fresh/decided variation on a NON-preview context (decisioning
764
+ # observability, not tracking a host listener may need to react to the
765
+ # decision even under consent denial); a preview-active context (qs-03,
766
+ # +@preview+ set) fires NO {SystemEvents::BUCKETING} event for ANY
767
+ # experience on this context, matching JS +context.ts:260+
768
+ # (+if (!this._preview) { fire BUCKETING }+). Independently, the outbound
769
+ # ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
520
770
  # composed verdict ({#tracking_enabled_for_call?} — global AND per-call); when +false+
521
771
  # the wire enqueue is suppressed with a +debug+ line and stickiness/decisioning
522
772
  # are untouched. Contained — a raising listener never crosses back (EventManager
523
773
  # swallows it); the enqueue is pure in-memory and inert when no ApiManager is wired.
524
774
  def fire_bucketing(experience_key, variation, track: true)
525
- @event_manager.fire(
526
- SystemEvents::BUCKETING,
527
- { visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
528
- nil,
529
- deferred: true
530
- )
775
+ if @preview.nil?
776
+ @event_manager.fire(
777
+ SystemEvents::BUCKETING,
778
+ { visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
779
+ nil,
780
+ deferred: true
781
+ )
782
+ end
531
783
  return if suppress_bucketing_enqueue?(track)
532
784
 
533
785
  enqueue_bucketing_event(variation)