shipeasy-sdk 3.1.1 → 3.3.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: e515d6b49fbcc5b0f080ff849666e2ddea3f1d5819b8fa3298755190d32cacb4
4
- data.tar.gz: 12b01b42ade6f5ee60ade42e4f6ee8af1bcb26999086499cefc3d6a4c39ee575
3
+ metadata.gz: 60cefd35bba21f86ef6b77da83d0c918744721d9c564066b12e8d2b95dba8740
4
+ data.tar.gz: 22b03ac85fbd29bba861770120c2a78bbcbc5069e0e7af0cbe8b1c33b8ebee88
5
5
  SHA512:
6
- metadata.gz: 73ad05284d98820cbd2adcb19d7b9e8d4fff78b96f75e236f0c1f66722a492653eb356ab371e65c1e25919b29d63f1483c241aafa9d9a6bb2c513a15fc3a3e8b
7
- data.tar.gz: 483dfab6644615b3f2774afed8182f6be4267d8f5302b58bc2fe5bf1edfce1d4cdf26b4587f5ff37a7b6eb52c1d6d57bb0434d789e047cf72f36206522102c03
6
+ metadata.gz: 226eccc8d981ce982ed132eb697c39e37cde9f9d62d9f84970cea6119601696383520bc1f03606abd58b81574be31d26962aedf2e000acfe45e1ac7bdb2c5f2c
7
+ data.tar.gz: 1e213dba4152299e0e0ad0d4480f02ca55c108d3fc7e5b70c966612799972a8b450fb24e5a74f4524e719d007994211fa903e6c5b76706012c9ba57f786b547e
data/docs/skill/SKILL.md CHANGED
@@ -60,9 +60,9 @@ flags.get_killswitch("payments") # true = killed; optional switch_ke
60
60
 
61
61
  # Ask the UNIVERSE, not the experiment. Returns an Assignment (never raises):
62
62
  # .name / .group → nil when not enrolled · .enrolled? → group non-nil
63
- # .get(field, fallback = nil) → variant override ?? universe default ?? fallback
64
- assignment = flags.universe("checkout").assign # auto-logs one deduped exposure when enrolled
65
- render_cta(assignment.get("label", "Buy now"))
63
+ # .get(field, fallback = nil, exposure: true) → variant override ?? universe default ?? fallback
64
+ assignment = flags.universe("checkout").assign # side-effect free; exposure fires on first get read
65
+ render_cta(assignment.get("label", "Buy now")) # first read logs one deduped exposure (exposure: false to peek)
66
66
 
67
67
  flags.track("purchase", { revenue: 49 }) # conversion / metric event
68
68
  ```
@@ -85,6 +85,13 @@ module Shipeasy
85
85
  :cdn_base_url, :loader_url,
86
86
  :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
87
87
 
88
+ # When true, `i18n_t` renders the translation KEY verbatim instead of
89
+ # resolving its value, so tests/snapshots assert against stable data instead
90
+ # of copy that changes when a translation is edited. nil (default) ⇒
91
+ # env-derived: on when the native env is "test" (RAILS_ENV / RACK_ENV /
92
+ # SHIPEASY_ENV / APP_ENV), off otherwise. Set true/false to override.
93
+ attr_accessor :render_keys_only
94
+
88
95
  def initialize
89
96
  @base_url = "https://api.shipeasy.ai"
90
97
  @attributes = nil
@@ -108,6 +115,16 @@ module Shipeasy
108
115
  @manifest_cache_ttl = 60
109
116
  @label_file_cache_ttl = 3600
110
117
  @http_timeout = 1
118
+ # nil ⇒ env-derived (on under RAILS_ENV/…=="test"); true/false overrides.
119
+ @render_keys_only = nil
120
+ end
121
+
122
+ # Resolve the effective render_keys_only decision: an explicit true/false set
123
+ # in the configure block wins; otherwise default to env==test.
124
+ def render_keys_only?
125
+ return @render_keys_only unless @render_keys_only.nil?
126
+
127
+ Shipeasy::SDK::Env.is_test_env
111
128
  end
112
129
  end
113
130
 
@@ -350,8 +350,13 @@ module Shipeasy
350
350
  candidates.each do |name, exp|
351
351
  result = eval_experiment(name, exp, u, flags_blob, exps_blob)
352
352
  next unless result.in_experiment
353
- post_exposure(u, name, result.group)
354
- landed = Eval::Assignment.new(name, result.group, result.params || {})
353
+ group = result.group
354
+ # On-read exposure (spec step 7): defer the single exposure to the
355
+ # first param read via the callback, instead of firing it here at
356
+ # assign time.
357
+ landed = Eval::Assignment.new(name, group, result.params || {}, lambda {
358
+ post_exposure(u, name, group)
359
+ })
355
360
  break
356
361
  # not enrolled: try the next candidate — under pooling only one slice
357
362
  # can match, so the loop lands on the winner (or falls through).
@@ -34,6 +34,10 @@ module Shipeasy
34
34
 
35
35
  def i18n_t(key, variables = {}, profile: nil, chunk: nil)
36
36
  config = Shipeasy.config
37
+ # render_keys_only (default: env==test): return the key verbatim,
38
+ # skipping value resolution + interpolation so tests assert stable data.
39
+ return key if config.render_keys_only?
40
+
37
41
  label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
38
42
  profile: profile || config.profile,
39
43
  chunk: chunk || config.default_chunk,
@@ -38,6 +38,14 @@ module Shipeasy
38
38
  (configured_env || "prod").to_s.strip.downcase == "prod"
39
39
  end
40
40
 
41
+ # True when the host runtime looks like a test run — the first present
42
+ # native env var (SHIPEASY_ENV / RAILS_ENV / RACK_ENV / APP_ENV) is exactly
43
+ # "test" (what Rails/RSpec set). Used to default i18n render_keys_only on
44
+ # under test. No native var present ⇒ not test.
45
+ def is_test_env
46
+ read_native_env == "test"
47
+ end
48
+
41
49
  # Read the first present native env var (lowercased, trimmed), or nil when
42
50
  # none of them is set to a non-empty value.
43
51
  def read_native_env
@@ -125,6 +125,28 @@ module Shipeasy
125
125
  # universe assign() path share ONE classify. The added steps are all guarded
126
126
  # by presence checks, so a legacy blob (no hashVersion/pool/reserved) behaves
127
127
  # exactly as before.
128
+ # Resolve a forced override group for +uid+ (spec step 1): ID overrides
129
+ # (tier 1) beat cohort/GK overrides (tier 2); within cohort overrides the
130
+ # first (pre-sorted by priority) gate that passes wins. Returns the forced
131
+ # group name or nil. The caller applies eligibility + group-existence
132
+ # (forced-but-gated). Mirrors @shipeasy/core resolveForcedGroup.
133
+ def self.resolve_forced_group(exp, uid, flags_blob, user)
134
+ id_overrides = exp["idOverrides"] || exp[:idOverrides]
135
+ if id_overrides
136
+ by_id = id_overrides[uid]
137
+ return by_id if by_id && !by_id.to_s.empty?
138
+ end
139
+ cohort_overrides = exp["cohortOverrides"] || exp[:cohortOverrides]
140
+ if cohort_overrides
141
+ cohort_overrides.each do |co|
142
+ gname = co["gate"] || co[:gate]
143
+ gate = flags_blob&.dig("gates", gname)
144
+ return (co["group"] || co[:group]) if gate && eval_gate(gate, user)
145
+ end
146
+ end
147
+ nil
148
+ end
149
+
128
150
  def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
129
151
  universe_name = exp && exp["universe"]
130
152
  universe = exps_blob&.dig("universes", universe_name)
@@ -172,6 +194,21 @@ module Shipeasy
172
194
  groups = exp["groups"] || []
173
195
  salt8 = (salt || "")[0, 8]
174
196
 
197
+ # Durable overrides (spec step 1, forced-but-gated). Reached only after the
198
+ # unit passes targeting and is not held out, so an override may now pin the
199
+ # group — bypassing allocation + the weighted pick but NOT the gates above.
200
+ # ID overrides (tier 1) beat cohort/GK overrides (tier 2); a forced group
201
+ # that no longer exists falls through to normal allocation. No-op when
202
+ # unconfigured, so v1/v2 stay byte-identical. Mirrors @shipeasy/core.
203
+ forced = resolve_forced_group(exp, uid, flags_blob, user)
204
+ if forced
205
+ g = groups.find { |x| x["name"] == forced }
206
+ if g
207
+ sticky_store.set(uid, exp_name, { "g" => forced, "s" => salt8 }) if sticky_store && exp_name
208
+ return as_group.call(g)
209
+ end
210
+ end
211
+
175
212
  # Sticky short-circuit: an enrolled unit whose stored salt prefix still
176
213
  # matches skips allocation and returns the stored group. If the stored
177
214
  # group no longer exists, fall through to re-bucket + overwrite.
@@ -232,14 +269,19 @@ module Shipeasy
232
269
  attr_reader :group
233
270
 
234
271
  # +params+ is already merged (universeDefaults ⊕ variantOverride) when
235
- # enrolled; defaults-only (or {}) when not.
236
- def initialize(name, group, params)
237
- @name = name
238
- @group = group
239
- @params = params || {}
272
+ # enrolled; defaults-only (or {}) when not. +on_expose+ fires the single
273
+ # exposure the first time an enrolled param is read (nil when not
274
+ # enrolled — nothing to expose); deduped downstream.
275
+ def initialize(name, group, params, on_expose = nil)
276
+ @name = name
277
+ @group = group
278
+ @params = params || {}
279
+ @on_expose = on_expose
280
+ @exposed = false
240
281
  end
241
282
 
242
283
  # True iff the unit is enrolled in an experiment in this universe.
284
+ # Reading it does NOT log an exposure (only +get+ of a param does).
243
285
  def enrolled?
244
286
  !@group.nil?
245
287
  end
@@ -248,7 +290,15 @@ module Shipeasy
248
290
  # universe default, else +fallback+. Works even when not enrolled (the
249
291
  # variant layer is absent, so you get universeDefault ?? fallback).
250
292
  # Looks up both string and symbol keys.
251
- def get(field, fallback = nil)
293
+ #
294
+ # Exposure is logged **on read** (spec step 7): the first enrolled read
295
+ # fires the single exposure; pass +exposure: false+ to read without
296
+ # logging (peek).
297
+ def get(field, fallback = nil, exposure: true)
298
+ if exposure && !@exposed && @on_expose
299
+ @exposed = true
300
+ @on_expose.call
301
+ end
252
302
  if @params.key?(field)
253
303
  @params[field]
254
304
  elsif field.respond_to?(:to_s) && @params.key?(field.to_s)
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "3.1.1"
3
+ VERSION = "3.3.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec