@shipeasy/sdk 6.3.1 → 7.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.
@@ -25,25 +25,6 @@ interface User {
25
25
  user_id?: string;
26
26
  [attr: string]: unknown;
27
27
  }
28
- interface ExperimentResult<P> {
29
- inExperiment: boolean;
30
- group: string;
31
- params: P;
32
- }
33
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
34
- * positional args plus per-call exposure control. */
35
- interface GetExperimentOptions<P> {
36
- /** Decode the raw stored params into the typed shape callers want. */
37
- decode?: (raw: unknown) => P;
38
- /** Variant-specific param overrides merged on top of group params. */
39
- variants?: Record<string, Partial<P>>;
40
- /**
41
- * Override automatic exposure logging for this read. Defaults to the client's
42
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
43
- * logging an exposure — pair with `logExposure(name)` at render time.
44
- */
45
- logExposure?: boolean;
46
- }
47
28
  /**
48
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
49
30
  * Computed at the client boundary:
@@ -75,11 +56,19 @@ interface EvalExpResult {
75
56
  inExperiment: boolean;
76
57
  group: string;
77
58
  params: Record<string, unknown>;
59
+ /** The universe this experiment belongs to — lets `universe(name).assign()`
60
+ * find the enrolled experiment within a universe. */
61
+ universe?: string;
78
62
  }
79
63
  interface EvalResponse {
80
64
  flags: Record<string, boolean>;
81
65
  configs: Record<string, unknown>;
82
66
  experiments: Record<string, EvalExpResult>;
67
+ /** Per-universe param defaults (name → default map) so `universe(name).get()`
68
+ * resolves to the universe default even when the unit is not enrolled. */
69
+ universes?: Record<string, {
70
+ defaults: Record<string, unknown>;
71
+ }>;
83
72
  /**
84
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
85
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -93,6 +82,23 @@ interface EvalResponse {
93
82
  */
94
83
  sticky?: Record<string, StickyCookieEntry>;
95
84
  }
85
+ /**
86
+ * The result of `universe(name).assign()` — the visitor's standing in a universe.
87
+ * A universe is a mutual-exclusion pool, so the visitor lands in **at most one**
88
+ * experiment. Never throws: an un-enrolled visitor still resolves `get()` to the
89
+ * universe defaults (or your fallback). `assign()` auto-logs one exposure when
90
+ * enrolled (subject to `disableAutoExposure`).
91
+ */
92
+ interface Assignment {
93
+ /** The experiment the visitor landed in, or `null` when not enrolled. */
94
+ readonly name: string | null;
95
+ /** The assigned variant/group name, or `null` when not enrolled. */
96
+ readonly group: string | null;
97
+ /** True iff the visitor is enrolled in an experiment in this universe. */
98
+ readonly enrolled: boolean;
99
+ /** Read a resolved param: variant override ?? universe default ?? `fallback`. */
100
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
101
+ }
96
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
97
103
  interface StickyCookieEntry {
98
104
  g: string;
@@ -104,6 +110,33 @@ interface AutoCollectGroups {
104
110
  engagement: boolean;
105
111
  }
106
112
  type EngineEnv = "dev" | "staging" | "prod";
113
+ /**
114
+ * A pluggable persistence backend for the anonymous id. Primarily for non-DOM
115
+ * runtimes (React Native / Expo) where there is no cookie or `localStorage`, so
116
+ * the anon id would otherwise regenerate every app launch and re-bucket the
117
+ * visitor. Point it at `@react-native-async-storage/async-storage` (or any
118
+ * store) and the SDK hydrates a stable id before the first `/sdk/evaluate`:
119
+ *
120
+ * ```ts
121
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
122
+ * configure({
123
+ * clientKey: "...",
124
+ * anonymousStore: {
125
+ * get: (k) => AsyncStorage.getItem(k),
126
+ * set: (k, v) => AsyncStorage.setItem(k, v),
127
+ * remove: (k) => AsyncStorage.removeItem(k),
128
+ * },
129
+ * });
130
+ * ```
131
+ *
132
+ * Methods may be sync or async — the SDK awaits either. Any thrown error is
133
+ * swallowed (the in-memory id is used as a fallback).
134
+ */
135
+ interface AnonymousStore {
136
+ get(key: string): string | null | Promise<string | null>;
137
+ set(key: string, value: string): void | Promise<void>;
138
+ remove(key: string): void | Promise<void>;
139
+ }
107
140
  interface EngineOptions {
108
141
  sdkKey: string;
109
142
  baseUrl?: string;
@@ -141,6 +174,14 @@ interface EngineOptions {
141
174
  * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
142
175
  */
143
176
  logLevel?: LogLevel;
177
+ /**
178
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
179
+ * last-resort guards catches an internal failure (an "on our end" bug), the
180
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
181
+ * apps — never to your project. ON by default; forced off in test mode. Pass
182
+ * `true` to disable.
183
+ */
184
+ disableInternalErrorReporting?: boolean;
144
185
  /**
145
186
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
146
187
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -164,6 +205,14 @@ interface EngineOptions {
164
205
  * lever. Pass `false` to opt out (pure deterministic eval).
165
206
  */
166
207
  stickyBucketing?: boolean;
208
+ /**
209
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
210
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
211
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
212
+ * app launches; on a fresh device it persists the freshly-minted id. See
213
+ * {@link AnonymousStore}.
214
+ */
215
+ anonymousStore?: AnonymousStore;
167
216
  /**
168
217
  * Test mode — no network at all. identify()/init are no-ops (never call
169
218
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -184,6 +233,7 @@ declare class Engine {
184
233
  private readonly env;
185
234
  private evalResult;
186
235
  private anonId;
236
+ private anonReady;
187
237
  private userId;
188
238
  private buffer;
189
239
  private telemetry;
@@ -198,6 +248,13 @@ declare class Engine {
198
248
  private readonly experimentOverrides;
199
249
  private onOverrideChange;
200
250
  constructor(opts: EngineOptions);
251
+ /**
252
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
253
+ * the visitor buckets identically across app launches, or persist the id just
254
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
255
+ * failure leaves the in-memory id in place.
256
+ */
257
+ private hydrateAnonFromStore;
201
258
  /**
202
259
  * Build a no-network, immediately-usable browser client for tests
203
260
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -251,16 +308,21 @@ declare class Engine {
251
308
  getFlag(name: string, defaultValue?: boolean): boolean;
252
309
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
253
310
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
254
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
255
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
256
311
  /**
257
- * Manually log an exposure for an enrolled experiment (Statsig's
258
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
259
- * the experiment, pushes the session-deduped exposure. Pair this with the
260
- * render of the treatment when reading with `{ logExposure: false }` (or
261
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
312
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
313
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
314
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
315
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
316
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
317
+ * `get()` to the universe defaults. This is the sole experiment read path —
318
+ * there is no `getExperiment` (ask a universe, not an experiment).
262
319
  */
263
- logExposure(name: string): void;
320
+ universe(name: string): {
321
+ assign(opts?: {
322
+ logExposure?: boolean;
323
+ }): Assignment;
324
+ };
325
+ private assignUniverse;
264
326
  /**
265
327
  * Subscribe to state changes — fires after identify() completes and on
266
328
  * `se:override:change` events from the devtools overlay. Returns an
@@ -25,25 +25,6 @@ interface User {
25
25
  user_id?: string;
26
26
  [attr: string]: unknown;
27
27
  }
28
- interface ExperimentResult<P> {
29
- inExperiment: boolean;
30
- group: string;
31
- params: P;
32
- }
33
- /** Options object form of `getExperiment` — the legacy `decode`/`variants`
34
- * positional args plus per-call exposure control. */
35
- interface GetExperimentOptions<P> {
36
- /** Decode the raw stored params into the typed shape callers want. */
37
- decode?: (raw: unknown) => P;
38
- /** Variant-specific param overrides merged on top of group params. */
39
- variants?: Record<string, Partial<P>>;
40
- /**
41
- * Override automatic exposure logging for this read. Defaults to the client's
42
- * setting (`disableAutoExposure` flips it). `false` reads the variant without
43
- * logging an exposure — pair with `logExposure(name)` at render time.
44
- */
45
- logExposure?: boolean;
46
- }
47
28
  /**
48
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
49
30
  * Computed at the client boundary:
@@ -75,11 +56,19 @@ interface EvalExpResult {
75
56
  inExperiment: boolean;
76
57
  group: string;
77
58
  params: Record<string, unknown>;
59
+ /** The universe this experiment belongs to — lets `universe(name).assign()`
60
+ * find the enrolled experiment within a universe. */
61
+ universe?: string;
78
62
  }
79
63
  interface EvalResponse {
80
64
  flags: Record<string, boolean>;
81
65
  configs: Record<string, unknown>;
82
66
  experiments: Record<string, EvalExpResult>;
67
+ /** Per-universe param defaults (name → default map) so `universe(name).get()`
68
+ * resolves to the universe default even when the unit is not enrolled. */
69
+ universes?: Record<string, {
70
+ defaults: Record<string, unknown>;
71
+ }>;
83
72
  /**
84
73
  * Killswitch state, flattened by the server. A boolean means the killswitch
85
74
  * is whole-killed; an object means it's not whole-killed and carries per-
@@ -93,6 +82,23 @@ interface EvalResponse {
93
82
  */
94
83
  sticky?: Record<string, StickyCookieEntry>;
95
84
  }
85
+ /**
86
+ * The result of `universe(name).assign()` — the visitor's standing in a universe.
87
+ * A universe is a mutual-exclusion pool, so the visitor lands in **at most one**
88
+ * experiment. Never throws: an un-enrolled visitor still resolves `get()` to the
89
+ * universe defaults (or your fallback). `assign()` auto-logs one exposure when
90
+ * enrolled (subject to `disableAutoExposure`).
91
+ */
92
+ interface Assignment {
93
+ /** The experiment the visitor landed in, or `null` when not enrolled. */
94
+ readonly name: string | null;
95
+ /** The assigned variant/group name, or `null` when not enrolled. */
96
+ readonly group: string | null;
97
+ /** True iff the visitor is enrolled in an experiment in this universe. */
98
+ readonly enrolled: boolean;
99
+ /** Read a resolved param: variant override ?? universe default ?? `fallback`. */
100
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
101
+ }
96
102
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
97
103
  interface StickyCookieEntry {
98
104
  g: string;
@@ -104,6 +110,33 @@ interface AutoCollectGroups {
104
110
  engagement: boolean;
105
111
  }
106
112
  type EngineEnv = "dev" | "staging" | "prod";
113
+ /**
114
+ * A pluggable persistence backend for the anonymous id. Primarily for non-DOM
115
+ * runtimes (React Native / Expo) where there is no cookie or `localStorage`, so
116
+ * the anon id would otherwise regenerate every app launch and re-bucket the
117
+ * visitor. Point it at `@react-native-async-storage/async-storage` (or any
118
+ * store) and the SDK hydrates a stable id before the first `/sdk/evaluate`:
119
+ *
120
+ * ```ts
121
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
122
+ * configure({
123
+ * clientKey: "...",
124
+ * anonymousStore: {
125
+ * get: (k) => AsyncStorage.getItem(k),
126
+ * set: (k, v) => AsyncStorage.setItem(k, v),
127
+ * remove: (k) => AsyncStorage.removeItem(k),
128
+ * },
129
+ * });
130
+ * ```
131
+ *
132
+ * Methods may be sync or async — the SDK awaits either. Any thrown error is
133
+ * swallowed (the in-memory id is used as a fallback).
134
+ */
135
+ interface AnonymousStore {
136
+ get(key: string): string | null | Promise<string | null>;
137
+ set(key: string, value: string): void | Promise<void>;
138
+ remove(key: string): void | Promise<void>;
139
+ }
107
140
  interface EngineOptions {
108
141
  sdkKey: string;
109
142
  baseUrl?: string;
@@ -141,6 +174,14 @@ interface EngineOptions {
141
174
  * `silent` < `error` < `warn` < `info` < `debug`. Defaults to `"warn"`.
142
175
  */
143
176
  logLevel?: LogLevel;
177
+ /**
178
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
179
+ * last-resort guards catches an internal failure (an "on our end" bug), the
180
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
181
+ * apps — never to your project. ON by default; forced off in test mode. Pass
182
+ * `true` to disable.
183
+ */
184
+ disableInternalErrorReporting?: boolean;
144
185
  /**
145
186
  * Suppress automatic exposure logging in `getExperiment` (Statsig's
146
187
  * `disableExposureLogging`). Default false — reading an enrolled variant
@@ -164,6 +205,14 @@ interface EngineOptions {
164
205
  * lever. Pass `false` to opt out (pure deterministic eval).
165
206
  */
166
207
  stickyBucketing?: boolean;
208
+ /**
209
+ * Pluggable persistence for the anonymous id (React Native / Expo, or any
210
+ * runtime without a cookie / `localStorage`). When set, the SDK hydrates the
211
+ * stored id before the first `/sdk/evaluate` so bucketing stays stable across
212
+ * app launches; on a fresh device it persists the freshly-minted id. See
213
+ * {@link AnonymousStore}.
214
+ */
215
+ anonymousStore?: AnonymousStore;
167
216
  /**
168
217
  * Test mode — no network at all. identify()/init are no-ops (never call
169
218
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -184,6 +233,7 @@ declare class Engine {
184
233
  private readonly env;
185
234
  private evalResult;
186
235
  private anonId;
236
+ private anonReady;
187
237
  private userId;
188
238
  private buffer;
189
239
  private telemetry;
@@ -198,6 +248,13 @@ declare class Engine {
198
248
  private readonly experimentOverrides;
199
249
  private onOverrideChange;
200
250
  constructor(opts: EngineOptions);
251
+ /**
252
+ * Consult the caller-supplied {@link AnonymousStore}: adopt a persisted id so
253
+ * the visitor buckets identically across app launches, or persist the id just
254
+ * minted by getOrCreateAnonId() on a fresh device. Best-effort — any store
255
+ * failure leaves the in-memory id in place.
256
+ */
257
+ private hydrateAnonFromStore;
201
258
  /**
202
259
  * Build a no-network, immediately-usable browser client for tests
203
260
  * (Statsig-style). identify() is a no-op (never calls /sdk/evaluate), track()
@@ -251,16 +308,21 @@ declare class Engine {
251
308
  getFlag(name: string, defaultValue?: boolean): boolean;
252
309
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
253
310
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
254
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
255
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
256
311
  /**
257
- * Manually log an exposure for an enrolled experiment (Statsig's
258
- * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
259
- * the experiment, pushes the session-deduped exposure. Pair this with the
260
- * render of the treatment when reading with `{ logExposure: false }` (or
261
- * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
312
+ * Assign the visitor within a universe: `universe("checkout").assign()`. A
313
+ * universe is a mutual-exclusion pool, so the visitor lands in ≤1 experiment;
314
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
315
+ * and auto-logs one exposure when enrolled (unless `disableAutoExposure` or
316
+ * `assign({ logExposure: false })`). An un-enrolled visitor still resolves
317
+ * `get()` to the universe defaults. This is the sole experiment read path —
318
+ * there is no `getExperiment` (ask a universe, not an experiment).
262
319
  */
263
- logExposure(name: string): void;
320
+ universe(name: string): {
321
+ assign(opts?: {
322
+ logExposure?: boolean;
323
+ }): Assignment;
324
+ };
325
+ private assignUniverse;
264
326
  /**
265
327
  * Subscribe to state changes — fires after identify() completes and on
266
328
  * `se:override:change` events from the devtools overlay. Returns an
@@ -125,6 +125,10 @@ interface ExperimentResult<P> {
125
125
  inExperiment: boolean;
126
126
  group: string;
127
127
  params: P;
128
+ /** The universe this experiment belongs to. Carried in the SSR bootstrap +
129
+ * `/sdk/evaluate` so the client can resolve `universe(name).assign()` by
130
+ * finding the enrolled experiment in a universe. */
131
+ universe?: string;
128
132
  }
129
133
  /**
130
134
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
@@ -170,13 +174,46 @@ interface ExperimentGroup {
170
174
  interface Experiment {
171
175
  universe: string;
172
176
  targetingGate?: string | null;
177
+ /**
178
+ * Per-experiment holdout flag (a `type='holdout'` gate). When set and the flag
179
+ * passes for a unit, that unit is *held out* of THIS experiment — never
180
+ * assigned, sees the universe defaults. Checked after the universe holdout,
181
+ * before allocation. Mirrors @shipeasy/core §B3.
182
+ */
183
+ holdoutGate?: string | null;
173
184
  allocationPct: number;
185
+ /**
186
+ * Contiguous half-open slice `[poolOffsetBp, poolOffsetBp + poolSizeBp)` of the
187
+ * *universe* hash space this experiment claims. Used only when `hashVersion >= 2`
188
+ * (pooled assignment / real mutual exclusion, §B4): the unit's universe segment
189
+ * must fall in this range to be in the experiment. Null ⇒ fall back to the
190
+ * legacy independent-salt `allocationPct` gate.
191
+ */
192
+ poolOffsetBp?: number | null;
193
+ poolSizeBp?: number | null;
194
+ /**
195
+ * A tail of the group split kept empty (basis points) so a new variant can be
196
+ * appended into it while running without reshuffling enrolled units. Group
197
+ * weights sum to `10000 − reservedHeadroomBp`; a unit hashing into the reserved
198
+ * tail is treated as not-assigned (sees universe defaults). §B5.
199
+ */
200
+ reservedHeadroomBp?: number | null;
174
201
  salt: string;
175
202
  groups: ExperimentGroup[];
176
203
  status: "draft" | "running" | "stopped" | "archived";
204
+ /** Bucketing scheme version. `>= 2` unlocks pooled (mutually-exclusive) assignment. */
205
+ hashVersion?: number;
177
206
  /** Attribute to bucket on (e.g. company_id); defaults to user_id/anonymous_id. */
178
207
  bucketBy?: string | null;
179
208
  }
209
+ /** One param the universe owns: a name, a type, and the default a variant
210
+ * inherits when it doesn't override the value (§A/§B2). */
211
+ interface UniverseParam {
212
+ name: string;
213
+ type: "bool" | "int" | "number" | "string";
214
+ default: unknown;
215
+ }
216
+ type UniverseParamSchema = UniverseParam[];
180
217
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
181
218
  interface StickyEntry {
182
219
  g: string;
@@ -197,6 +234,13 @@ interface StickyBucketStore {
197
234
  declare function createInMemoryStickyStore(seed?: Record<string, Record<string, StickyEntry>>): StickyBucketStore;
198
235
  interface Universe {
199
236
  holdout_range: [number, number] | null;
237
+ /**
238
+ * The universe's config schema — the single source of truth for param names,
239
+ * types, and defaults (§A). The `assign()` merge layers these defaults *under*
240
+ * an assigned variant's overrides so an unset param still returns the universe
241
+ * default. Null/absent ⇒ no defaults (legacy: variant params returned verbatim).
242
+ */
243
+ param_schema?: UniverseParamSchema | null;
200
244
  }
201
245
  interface Killswitch {
202
246
  killed: 0 | 1 | boolean;
@@ -223,8 +267,36 @@ interface BootstrapPayload {
223
267
  configs: Record<string, unknown>;
224
268
  experiments: Record<string, ExperimentResult<Record<string, unknown>>>;
225
269
  killswitches: Record<string, boolean | Record<string, boolean>>;
270
+ /** Per-universe param defaults (name → default map) so the client can resolve
271
+ * `universe(name).get(field)` to the universe default even when the unit is
272
+ * not enrolled in any experiment there. Only universes with running
273
+ * experiments are included. */
274
+ universes?: Record<string, {
275
+ defaults: Record<string, unknown>;
276
+ }>;
226
277
  }
227
278
  declare const ANON_ID_COOKIE = "__se_anon_id";
279
+ /**
280
+ * The result of `universe(name).assign(user)` — a user's standing in a universe.
281
+ * A universe is a mutual-exclusion pool, so a unit lands in **at most one**
282
+ * experiment. Never throws: an un-enrolled unit still resolves `get()` to the
283
+ * universe defaults (or your fallback). Reading is side-effect free — the single
284
+ * exposure is logged once by `assign()` when the unit is enrolled.
285
+ */
286
+ interface Assignment {
287
+ /** The experiment the unit landed in, or `null` when not enrolled. */
288
+ readonly name: string | null;
289
+ /** The assigned variant/group name, or `null` when not enrolled. */
290
+ readonly group: string | null;
291
+ /** True iff the unit is enrolled in an experiment in this universe. */
292
+ readonly enrolled: boolean;
293
+ /**
294
+ * Read a resolved param: the assigned variant's override, else the universe
295
+ * default, else `fallback`. Works even when not enrolled (variant layer is
296
+ * absent, so you get `universeDefault ?? fallback`).
297
+ */
298
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
299
+ }
228
300
  type EngineEnv = "dev" | "staging" | "prod";
229
301
  interface EngineOptions {
230
302
  apiKey: string;
@@ -257,6 +329,14 @@ interface EngineOptions {
257
329
  * SDK entirely. See {@link LogLevel}.
258
330
  */
259
331
  logLevel?: LogLevel;
332
+ /**
333
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
334
+ * last-resort guards catches an internal failure (an "on our end" bug), the
335
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
336
+ * apps — never to your project. ON by default; forced off in test mode. Pass
337
+ * `true` to disable.
338
+ */
339
+ disableInternalErrorReporting?: boolean;
260
340
  /**
261
341
  * Attribute names usable for targeting but never persisted in analytics
262
342
  * (LD/Statsig `privateAttributes`). The server evaluates locally so private
@@ -299,6 +379,7 @@ declare class Engine {
299
379
  private readonly flagOverrides;
300
380
  private readonly configOverrides;
301
381
  private readonly experimentOverrides;
382
+ private readonly exposureSeen;
302
383
  private readonly changeListeners;
303
384
  constructor(opts: EngineOptions);
304
385
  /**
@@ -378,19 +459,47 @@ declare class Engine {
378
459
  getFlag(name: string, user: User, defaultValue?: boolean): boolean;
379
460
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
380
461
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
381
- getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
462
+ /**
463
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
464
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
465
+ */
466
+ private bindSticky;
467
+ /**
468
+ * Evaluate one experiment by name for `user` — override → full classify
469
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
470
+ * → group), merging the universe defaults under the assigned variant (§B2).
471
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
472
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
473
+ */
474
+ private evalExperiment;
475
+ /**
476
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
477
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
478
+ * exposes the variant + resolved params and auto-logs a single exposure when
479
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
480
+ * Never throws. This is the sole experiment read path (there is no
481
+ * `getExperiment` — a caller asks a universe, not an experiment).
482
+ */
483
+ assignUniverse(universeName: string, user: User): Assignment;
484
+ /**
485
+ * The universe-first experiment read entry point:
486
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
487
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
488
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
489
+ */
490
+ universe(name: string): {
491
+ assign(user: User): Assignment;
492
+ };
382
493
  /** Drop caller-marked private attributes from an outbound props bag. */
383
494
  private stripPrivate;
384
495
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
385
496
  /**
386
- * Emit an exposure event for an experiment at the server-side decision point
387
- * (parity with the browser's auto-exposure). The server is stateless and
388
- * never auto-logs, so call this when you actually present the treatment.
389
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
390
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
391
- * `/collect`. No-op in test mode or when the user isn't enrolled.
497
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
498
+ * per process (bounded set) so repeated `assign()` calls in one server don't
499
+ * spam `/collect`. Fire-and-forget; no-op in test mode. This is how
500
+ * `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
392
501
  */
393
- logExposure(user: string | User, name: string): void;
502
+ private postExposure;
394
503
  /**
395
504
  * Report a structured error into the errors primitive. Fire-and-forget —
396
505
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -487,6 +596,13 @@ interface ShipeasyServerConfig {
487
596
  * that evaluate many flags per request. See {@link EngineOptions.disableTelemetry}.
488
597
  */
489
598
  disableTelemetry?: boolean;
599
+ /**
600
+ * Opt out of SDK-internal error self-monitoring. Internal SDK failures ("on
601
+ * our end") are reported to Shipeasy's own project (never yours) so we can
602
+ * track SDK bugs. ON by default. See
603
+ * {@link EngineOptions.disableInternalErrorReporting}.
604
+ */
605
+ disableInternalErrorReporting?: boolean;
490
606
  /**
491
607
  * Attribute names usable for targeting but never persisted in analytics
492
608
  * (LD/Statsig `privateAttributes`). Stripped from every outbound `track()`
@@ -582,7 +698,17 @@ declare const flags: {
582
698
  /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
583
699
  getDetail(name: string, user: User): FlagDetail;
584
700
  getConfig<T = unknown>(name: string, decodeOrOpts?: ((raw: unknown) => T) | GetConfigOptions<T>): T | undefined;
585
- getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
701
+ /**
702
+ * Assign `user` within a universe: `flags.universe("checkout").assign(user)`.
703
+ * A universe is a mutual-exclusion pool, so the unit lands in ≤1 experiment;
704
+ * the returned {@link Assignment} exposes `.group` / `.get(field, fallback)`
705
+ * and auto-logs one exposure when enrolled. Before configure() (or on any
706
+ * error) it returns a safe not-enrolled handle. This replaces the removed
707
+ * `getExperiment` — read experiments by universe, never by name.
708
+ */
709
+ universe(name: string): {
710
+ assign(user: User): Assignment;
711
+ };
586
712
  /**
587
713
  * Read a killswitch. Without `switchKey`, returns true when the whole
588
714
  * killswitch is killed. With `switchKey`, returns true when that specific
@@ -590,9 +716,6 @@ declare const flags: {
590
716
  */
591
717
  ks(name: string, switchKey?: string): boolean;
592
718
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
593
- /** Emit an exposure for an enrolled experiment at the decision point. See
594
- * {@link Engine.logExposure}. No-op before configure(). */
595
- logExposure(user: string | User, name: string): void;
596
719
  /**
597
720
  * Evaluate all flags / configs / experiments for a user against the locally
598
721
  * cached blob. Pass the request URL to apply ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -740,7 +863,15 @@ declare class Client<U = unknown> {
740
863
  getFlagDetail(name: string): FlagDetail;
741
864
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
742
865
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
743
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
866
+ /**
867
+ * Assign the bound user within a universe: `client.universe("checkout").assign()`.
868
+ * The user is already bound at construction, so `assign()` takes no arg. Returns
869
+ * an {@link Assignment} (`.group` / `.get(field, fallback)`) and auto-logs one
870
+ * exposure when enrolled. Replaces the removed `getExperiment`.
871
+ */
872
+ universe(name: string): {
873
+ assign(): Assignment;
874
+ };
744
875
  /** Read a killswitch (not user-bound; mirrors {@link Engine.getKillswitch}). */
745
876
  getKillswitch(name: string, switchKey?: string): boolean;
746
877
  /**
@@ -751,13 +882,6 @@ declare class Client<U = unknown> {
751
882
  * mode.
752
883
  */
753
884
  track(eventName: string, props?: Record<string, unknown>): void;
754
- /**
755
- * Emit an exposure event for `name` at this server-side decision point for the
756
- * bound user. Delegates to {@link Engine.logExposure} with the resolved
757
- * attribute bag (re-evaluates enrolment; no-op when the user isn't enrolled or
758
- * in test mode).
759
- */
760
- logExposure(name: string): void;
761
885
  }
762
886
  interface SeeApi {
763
887
  /**
@@ -810,4 +934,4 @@ interface SeeApi {
810
934
  */
811
935
  declare const see: SeeApi;
812
936
 
813
- export { ANON_ID_COOKIE, type AttributesFn, type BootstrapData, type BootstrapEmitOptions, type BootstrapPayload, Client, type ConfigureOfflineOptions, type ConfigureOptions, type ConfigureTestOptions, type Consequence, Engine, type EngineEnv, type EngineOptions, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, type GetConfigOptions, type I18nForRequest, LOG_LEVELS, type LabelFile, type LogLevel, type ScriptTagSpec, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type StickyBucketStore, type StickyEntry, type User, type Violation, _murmur3ForTests, _resetConfigureForTests, _resetShipeasyServerForTests, clearOverrides, configure, configureForOffline, configureForTesting, configureShipeasyServer, createInMemoryStickyStore, fetchLabelsForSSR, flags, getBootstrapData, getBootstrapTags, getShipeasyServerClient, i18n, isExpected, onChange, overrideConfig, overrideExperiment, overrideFlag, see, seeContext, shipeasy, version };
937
+ export { ANON_ID_COOKIE, type Assignment, type AttributesFn, type BootstrapData, type BootstrapEmitOptions, type BootstrapPayload, Client, type ConfigureOfflineOptions, type ConfigureOptions, type ConfigureTestOptions, type Consequence, Engine, type EngineEnv, type EngineOptions, type ExperimentResult, type ExpsBlob, FLAG_REASONS, type FetchLabelsOptions, type FlagDetail, type FlagReason, type FlagsBlob, type GetConfigOptions, type I18nForRequest, LOG_LEVELS, type LabelFile, type LogLevel, type ScriptTagSpec, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyServerConfig, type ShipeasyServerHandle, type StickyBucketStore, type StickyEntry, type UniverseParam, type UniverseParamSchema, type User, type Violation, _murmur3ForTests, _resetConfigureForTests, _resetShipeasyServerForTests, clearOverrides, configure, configureForOffline, configureForTesting, configureShipeasyServer, createInMemoryStickyStore, fetchLabelsForSSR, flags, getBootstrapData, getBootstrapTags, getShipeasyServerClient, i18n, isExpected, onChange, overrideConfig, overrideExperiment, overrideFlag, see, seeContext, shipeasy, version };