shipeasy-sdk 3.5.0 → 3.6.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: 0453ca27510264bd268b40868bb5c55a942835b7a20076b11e19305b4eeb27a7
4
- data.tar.gz: c9c0b1ee3c80cf28abac5af4ec8bdaec1613f8b3235708b79b27223e38d2d463
3
+ metadata.gz: ad9abca98f87069daf53c2e2347e9f2975595b437576738aef381fbc2a4a9a10
4
+ data.tar.gz: 300a69f50db74da6279666d9d6d8698dbb22d08365e846f20403609e001ea638
5
5
  SHA512:
6
- metadata.gz: d92ea1c2016d7e81791a7d7b8d6a0cd631da001893227685297e570c86372dc905da89cf54641d85ef7715e6ffd5ae0498a8781714326352fc139cf5adae0320
7
- data.tar.gz: bfd396eeac063d46053f15fc865f8444f8284dae480cee7f064809a43bb288cd48ebd7cf559d08d5d3f0f68c3252a360800a8a2f787c5b63e2a3c25601e8d555
6
+ metadata.gz: cfb4185054243325d9130de44d40fff6d28af91ee2100150a7f594c2a9925c8ab20f931a2d1ccb433b830521ac07b7ff2381ff2325cf3b1ada0591d674f30c0d
7
+ data.tar.gz: 5b89a4b38c9f6b9eebc9946e50ebd74bbfb5336c0354cee43321b7ea94652fff7da669deebf74317a7aee6492db6760e471ea29b0c375885474e5ab2e37fbdab
@@ -485,6 +485,8 @@ module Shipeasy
485
485
  attr("data-api-url", base),
486
486
  ]
487
487
  attrs << attr("data-anon-id", anon_id) if anon_id && !anon_id.empty?
488
+ data_user = identity_attrs(user)
489
+ attrs << attr("data-user", data_user) if data_user
488
490
  %(<script src="#{CGI.escapeHTML("#{base}/sdk/bootstrap.js")}" #{attrs.join(' ')}></script>)
489
491
  end
490
492
 
@@ -745,6 +747,28 @@ module Shipeasy
745
747
  %(#{name}="#{CGI.escapeHTML(value.to_s)}")
746
748
  end
747
749
 
750
+ # Serialize the server-identified user's traits for the SSR bootstrap tag's
751
+ # data-user attribute: the request user minus `anonymous_id`, dropping
752
+ # nil values only (empty strings and other present values are kept, to
753
+ # match the cross-SDK contract). Returns a stable-keyed JSON object, or nil when nothing
754
+ # identified remains (a purely anonymous request) so data-user is omitted.
755
+ # The browser SDK adopts this identity on first paint, killing the
756
+ # anon->identified flip. See experiment-platform/18-identity-bucketing.md.
757
+ def identity_attrs(user)
758
+ return nil unless user.is_a?(Hash)
759
+
760
+ traits = {}
761
+ user.each do |k, v|
762
+ next if k.to_s == "anonymous_id"
763
+ next if v.nil?
764
+
765
+ traits[k.to_s] = v
766
+ end
767
+ return nil if traits.empty?
768
+
769
+ JSON.generate(traits.sort.to_h)
770
+ end
771
+
748
772
  def start_poll
749
773
  @timer = Thread.new do
750
774
  loop do
@@ -70,10 +70,102 @@ module Shipeasy
70
70
  user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
71
71
  end
72
72
 
73
+ def self.clamp_pct(n)
74
+ return 0 if n < 0
75
+ return 10000 if n > 10000
76
+ n
77
+ end
78
+
79
+ # Effective rollout % for a stack entry at time +now+ (epoch ms). A
80
+ # condition with no explicit rolloutPct defaults to 100% (match => pass); a
81
+ # rollout to 0%. A ramp linearly interpolates from=>to over
82
+ # [startAt, startAt + durationMs] via truncating-toward-zero division — the
83
+ # cross-SDK contract (experiment-platform/04-evaluation.md). Mirrors core's
84
+ # effectivePct.
85
+ def self.effective_pct(entry, now)
86
+ type = entry["type"] || entry[:type]
87
+ raw = entry["rolloutPct"] || entry[:rolloutPct]
88
+ base = raw.nil? ? (type == "condition" ? 10000 : 0) : raw
89
+ ramp = entry["ramp"] || entry[:ramp]
90
+ return base unless ramp
91
+
92
+ from = ramp["from"] || ramp[:from]
93
+ to = ramp["to"] || ramp[:to]
94
+ start_at = ramp["startAt"] || ramp[:startAt]
95
+ duration = ramp["durationMs"] || ramp[:durationMs]
96
+ return from if now <= start_at
97
+ return to if now >= start_at + duration
98
+
99
+ delta = to - from # signed
100
+ elapsed = now - start_at
101
+ # .to_i on the float quotient truncates toward zero (works for a negative
102
+ # ramp-down delta, unlike Ruby's floor-division integer `/`).
103
+ clamp_pct(from + (delta * elapsed).fdiv(duration).to_i)
104
+ end
105
+
106
+ # Hash the caller into [0, 10000) and test against +pct+. No-unit contract
107
+ # (experiment-platform/18): a fully-rolled bucket is on for everyone without
108
+ # a unit id; a fractional one needs a stable unit, so it is off. Mirrors
109
+ # core's bucketHit.
110
+ def self.bucket_hit(pct, uid, salt)
111
+ return false if pct <= 0
112
+ return pct >= 10000 if uid.nil? || uid.to_s.empty?
113
+ return true if pct >= 10000
114
+ murmur3("#{salt}:#{uid}") % 10000 < pct
115
+ end
116
+
117
+ # Evaluate one gatekeeper stack entry. A `condition` gates on its rules
118
+ # (pass: "all" | "any") then buckets at its own rollout % (default 100%); a
119
+ # `rollout` buckets everyone who reached it (default 0%). A condition's
120
+ # default bucketing salt is its own id so each step buckets independently;
121
+ # a rollout falls back to the gate salt so existing entries don't re-bucket.
122
+ # Mirrors core's evalStackEntry.
123
+ def self.eval_stack_entry(entry, user, fallback_salt, now)
124
+ type = entry["type"] || entry[:type]
125
+ bucket_by = entry["bucketBy"] || entry[:bucketBy]
126
+ entry_salt = entry["salt"] || entry[:salt]
127
+
128
+ if type == "condition"
129
+ rules = entry["rules"] || entry[:rules] || []
130
+ return false if rules.empty?
131
+
132
+ mode = entry["pass"] || entry[:pass] || "all"
133
+ matched =
134
+ if mode == "any"
135
+ rules.any? { |r| match_rule(r, user) }
136
+ else
137
+ rules.all? { |r| match_rule(r, user) }
138
+ end
139
+ return false unless matched
140
+
141
+ salt = (entry_salt && !entry_salt.to_s.empty?) ? entry_salt : (entry["id"] || entry[:id] || fallback_salt)
142
+ bucket_hit(effective_pct(entry, now), pick_identifier(user, bucket_by), salt)
143
+ else
144
+ salt = (entry_salt && !entry_salt.to_s.empty?) ? entry_salt : fallback_salt
145
+ bucket_hit(effective_pct(entry, now), pick_identifier(user, bucket_by), salt)
146
+ end
147
+ end
148
+
73
149
  def self.eval_gate(gate, user)
74
150
  return false if enabled?(gate["killswitch"])
75
151
  return false unless enabled?(gate["enabled"])
76
152
 
153
+ # Modern gatekeepers ship an ordered `stack`; evaluate it top-to-bottom and
154
+ # pass on the first entry whose rules match AND whose bucket hits. This is
155
+ # the canonical model — the flat `rules`/`rolloutPct` below are a lossy
156
+ # approximation (a whitelist condition at 100% collapses to `rolloutPct: 0`
157
+ # once the public rollout is 0%, which the flat path would wrongly read as
158
+ # "never"). Mirrors @shipeasy/core evalGatekeeper — keep the two in sync.
159
+ stack = gate["stack"] || gate[:stack]
160
+ if stack.is_a?(Array) && !stack.empty?
161
+ now = (Time.now.to_f * 1000).to_i
162
+ gate_salt = gate["salt"] || gate[:salt]
163
+ stack.each do |entry|
164
+ return true if eval_stack_entry(entry, user, gate_salt, now)
165
+ end
166
+ return false
167
+ end
168
+
77
169
  (gate["rules"] || []).each do |rule|
78
170
  return false unless match_rule(rule, user)
79
171
  end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "3.5.0"
3
+ VERSION = "3.6.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.5.0
4
+ version: 3.6.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-17 00:00:00.000000000 Z
11
+ date: 2026-07-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec