@variantlab/core 0.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.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/_size/config.cjs +634 -0
- package/dist/_size/config.cjs.map +1 -0
- package/dist/_size/config.d.cts +2 -0
- package/dist/_size/config.d.ts +2 -0
- package/dist/_size/config.js +631 -0
- package/dist/_size/config.js.map +1 -0
- package/dist/_size/engine.cjs +1178 -0
- package/dist/_size/engine.cjs.map +1 -0
- package/dist/_size/engine.d.cts +2 -0
- package/dist/_size/engine.d.ts +2 -0
- package/dist/_size/engine.js +1175 -0
- package/dist/_size/engine.js.map +1 -0
- package/dist/_size/targeting.cjs +332 -0
- package/dist/_size/targeting.cjs.map +1 -0
- package/dist/_size/targeting.d.cts +97 -0
- package/dist/_size/targeting.d.ts +97 -0
- package/dist/_size/targeting.js +325 -0
- package/dist/_size/targeting.js.map +1 -0
- package/dist/config-B3DTOt1J.d.ts +46 -0
- package/dist/config-U0cqXPTa.d.cts +46 -0
- package/dist/engine-BEuGiH3G.d.cts +173 -0
- package/dist/engine-BoNBfZBL.d.ts +173 -0
- package/dist/index.cjs +1352 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +223 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +1324 -0
- package/dist/index.js.map +1 -0
- package/dist/types-BkXPpEyg.d.cts +82 -0
- package/dist/types-BkXPpEyg.d.ts +82 -0
- package/package.json +58 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { E as Experiment, a as ExperimentsConfig } from './types-BkXPpEyg.js';
|
|
2
|
+
export { A as AssignmentStrategy, R as RollbackConfig, T as Targeting, V as Variant, b as VariantContext } from './types-BkXPpEyg.js';
|
|
3
|
+
export { C as ConfigIssue, a as ConfigValidationError, I as IssueCode, v as validateConfig } from './config-B3DTOt1J.js';
|
|
4
|
+
export { E as EngineEvent, a as EngineOptions, F as FailMode, L as Listener, b as ListenerSet, U as UnknownExperimentError, V as VariantChangeSource, c as VariantEngine, d as createEngine } from './engine-BoNBfZBL.js';
|
|
5
|
+
export { EvalContext, EvaluableTargeting, ExplainField, ExplainResult, ExplainStep, TargetingResult, evaluate, explain, hashUserId, matchRoute, matchSemver, matchTargeting } from './_size/targeting.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default assignment: always returns the experiment's `default`
|
|
9
|
+
* variant. Used when no strategy is configured, when the user is
|
|
10
|
+
* not yet bucketable (no `userId`), or as the fallback in
|
|
11
|
+
* fail-open error paths.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
declare function assignDefault(experiment: Experiment): string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Synchronous 32-bit string hash for deterministic assignment.
|
|
18
|
+
*
|
|
19
|
+
* `getVariant` is required to be synchronous and O(1) (see
|
|
20
|
+
* `ARCHITECTURE.md` runtime data flow and the design principle
|
|
21
|
+
* of cheap, render-safe reads). Web Crypto's `subtle.digest` is
|
|
22
|
+
* always async, so we hand-roll a tiny hash here:
|
|
23
|
+
*
|
|
24
|
+
* - FNV-1a (32-bit) core for fast streaming.
|
|
25
|
+
* - Murmur3 finalizer for avalanche (uniform bit distribution).
|
|
26
|
+
*
|
|
27
|
+
* Total: ~150 bytes gzipped. Deterministic across every runtime we
|
|
28
|
+
* target (Node 18+, browsers, RN, Deno, Bun, Edge) because every
|
|
29
|
+
* step is a 32-bit integer operation with defined overflow semantics
|
|
30
|
+
* via `Math.imul` and `>>> 0`.
|
|
31
|
+
*
|
|
32
|
+
* This is **not** cryptographic — do not use for signing. The async
|
|
33
|
+
* sha256 path in `../targeting/hash.ts` remains available for
|
|
34
|
+
* advanced use cases. The phase-0 "which hash algorithm" open
|
|
35
|
+
* question in `docs/phases/phase-0-foundation.md` resolves here.
|
|
36
|
+
*/
|
|
37
|
+
/** FNV-1a 32-bit hash + Murmur3 finalizer. Returns a uint32. */
|
|
38
|
+
declare function hash32(input: string): number;
|
|
39
|
+
/** Map a userId to a [0, 100) bucket. Used by user-id hash-mod targeting. */
|
|
40
|
+
declare function bucketUserId(userId: string): number;
|
|
41
|
+
|
|
42
|
+
declare function resolveMutex(userId: string, mutexGroup: string, candidateIds: readonly string[]): string | undefined;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* "Random" assignment.
|
|
46
|
+
*
|
|
47
|
+
* In phase 1 MVP this is behaviorally identical to `sticky-hash`:
|
|
48
|
+
* a deterministic function of `(userId, experimentId)`. The two
|
|
49
|
+
* strategies exist as distinct config values because the contract
|
|
50
|
+
* differs:
|
|
51
|
+
*
|
|
52
|
+
* - `sticky-hash` — promised to be stable across devices for the
|
|
53
|
+
* same `userId`, zero storage needed.
|
|
54
|
+
* - `random` — the engine is allowed to bucket the user
|
|
55
|
+
* however it likes as long as each user sees a consistent
|
|
56
|
+
* answer. Future phases may back this with storage-cached
|
|
57
|
+
* uniform-random assignments.
|
|
58
|
+
*
|
|
59
|
+
* The non-negotiable rule (design-principles.md §performance &
|
|
60
|
+
* ARCHITECTURE.md §core invariants): **no `Math.random` in the hot
|
|
61
|
+
* path**. Delegating to `assignStickyHash` keeps us honest and
|
|
62
|
+
* collapses both paths to the same tree-shaken code.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
declare function assignRandom(experiment: Experiment, userId: string): string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sticky-hash assignment.
|
|
69
|
+
*
|
|
70
|
+
* Deterministic from `(userId, experimentId)`: hash the pair with
|
|
71
|
+
* the sync 32-bit hash and map the result modulo the sorted variant
|
|
72
|
+
* count. Sort order is lexicographic on `variant.id` so that the
|
|
73
|
+
* mapping is stable across processes, devices, and restarts.
|
|
74
|
+
*
|
|
75
|
+
* Adding a new variant renumbers existing users (the classic
|
|
76
|
+
* hash-mod trade-off). This is acceptable for phase 1 — the
|
|
77
|
+
* config-format doc calls out that variant additions are effectively
|
|
78
|
+
* a new experiment. Rendezvous hashing would fix this but costs
|
|
79
|
+
* ~300 bytes we don't want to spend before someone asks for it.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
declare function assignStickyHash(experiment: Experiment, userId: string): string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Weighted assignment.
|
|
86
|
+
*
|
|
87
|
+
* Maps `(userId, experimentId)` to a `[0, 10000)` bucket via the
|
|
88
|
+
* sync hash, then walks the split's cumulative boundaries in
|
|
89
|
+
* sorted-key order. `split` is validated at config-load time to
|
|
90
|
+
* sum to exactly 100, so we multiply by 100 internally to get an
|
|
91
|
+
* integer 10000-unit range without floating point.
|
|
92
|
+
*
|
|
93
|
+
* Determinism: same user, same experiment, same split → same
|
|
94
|
+
* variant forever. Changing the split re-buckets everyone, which
|
|
95
|
+
* is the documented behavior in `config-format.md`.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
declare function assignWeighted(experiment: Experiment, userId: string): string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Assignment barrel.
|
|
102
|
+
*
|
|
103
|
+
* `assignVariant` is the single entry point used by the engine hot
|
|
104
|
+
* path. It dispatches on `experiment.assignment` and falls back to
|
|
105
|
+
* the default variant whenever the user isn't bucketable (missing
|
|
106
|
+
* `userId`) — every non-`default` strategy depends on a stable
|
|
107
|
+
* identity to be deterministic.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
declare function assignVariant(experiment: Experiment, userId: string | undefined): string;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Deterministic JSON serializer (RFC 8785-inspired, not strict).
|
|
114
|
+
*
|
|
115
|
+
* - Object keys are sorted by UTF-16 code unit (`Array#sort` default).
|
|
116
|
+
* - Only own enumerable keys are serialized.
|
|
117
|
+
* - `undefined` values on objects are dropped; `undefined` in arrays
|
|
118
|
+
* is emitted as `null` (matches `JSON.stringify`).
|
|
119
|
+
* - Finite numbers only; `NaN`/`Infinity`/`-Infinity` throw.
|
|
120
|
+
* - `BigInt`, functions, and symbols throw.
|
|
121
|
+
* - No whitespace in the output.
|
|
122
|
+
* - A depth cap of 512 guards against accidental unbounded recursion.
|
|
123
|
+
*
|
|
124
|
+
* The output is the canonical form used as input to HMAC signing
|
|
125
|
+
* (Phase 2). It is **not** used for the 1 MB size check — that is
|
|
126
|
+
* measured against the raw input bytes.
|
|
127
|
+
*/
|
|
128
|
+
declare function canonicalStringify(value: unknown): string;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Recursively freeze a plain-JSON-shaped value.
|
|
132
|
+
*
|
|
133
|
+
* - Primitives (including `null`/`undefined`) are returned as-is.
|
|
134
|
+
* - Arrays and plain objects are frozen in-place with `Object.freeze`.
|
|
135
|
+
* - Already-frozen subtrees are skipped (short-circuit) so this is
|
|
136
|
+
* cheap to call on shared references and idempotent.
|
|
137
|
+
*
|
|
138
|
+
* Sanitized config trees are tree-shaped (no cycles, no class
|
|
139
|
+
* instances) so no cycle detection is required.
|
|
140
|
+
*/
|
|
141
|
+
declare function deepFreeze<T>(value: T): T;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* In-memory crash counter for crash-rollback.
|
|
145
|
+
*
|
|
146
|
+
* For each experiment, we maintain a sliding window of crash
|
|
147
|
+
* timestamps. `record()` appends, `countWithin()` drops expired
|
|
148
|
+
* entries and returns the current count. Persistence across
|
|
149
|
+
* process restarts is phase 4 (`rollback.persistent`) — the phase 1
|
|
150
|
+
* MVP only protects users within a single session.
|
|
151
|
+
*/
|
|
152
|
+
declare class CrashCounter {
|
|
153
|
+
private readonly crashes;
|
|
154
|
+
/** Record a crash; returns the new in-window count (post-prune). */
|
|
155
|
+
record(experimentId: string, now: number, window: number): number;
|
|
156
|
+
countWithin(experimentId: string, now: number, window: number): number;
|
|
157
|
+
clear(experimentId?: string): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Kill-switch logic.
|
|
162
|
+
*
|
|
163
|
+
* Returns `true` when an experiment should short-circuit to its
|
|
164
|
+
* default variant for a reason that isn't context-specific:
|
|
165
|
+
*
|
|
166
|
+
* - Global `config.enabled === false` — admin kill switch for
|
|
167
|
+
* fast incident response.
|
|
168
|
+
* - `status === "archived"` — the experiment is over, keep the
|
|
169
|
+
* config around for history but never evaluate it.
|
|
170
|
+
* - `status === "draft"` — the experiment is being authored; in
|
|
171
|
+
* production this behaves the same as archived (returns the
|
|
172
|
+
* default), the debug overlay shows it with a badge so devs
|
|
173
|
+
* can still preview it.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
declare function isKilled(config: ExperimentsConfig, experiment: Experiment): boolean;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Start/end date gate.
|
|
180
|
+
*
|
|
181
|
+
* `startDate` is inclusive, `endDate` is exclusive. Matches the
|
|
182
|
+
* semantics documented in `config-format.md` §`startDate / endDate`.
|
|
183
|
+
*
|
|
184
|
+
* `now` is injected (not read via `Date.now()` here) so that the
|
|
185
|
+
* engine can snapshot time at a single point per resolution and
|
|
186
|
+
* so that tests can pin time without mocking globals. The engine
|
|
187
|
+
* calls `Date.now()` once per `getVariant` call at the outer
|
|
188
|
+
* boundary.
|
|
189
|
+
*
|
|
190
|
+
* Malformed ISO strings fail-closed (the gate returns `true` to
|
|
191
|
+
* keep the experiment inactive) — a broken date is a config
|
|
192
|
+
* mistake the validator catches at load time, but defending in
|
|
193
|
+
* depth here is ~10 bytes we're happy to spend.
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
declare function isTimeGated(experiment: Experiment, now: number): boolean;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fixed-size ring buffer for engine event history.
|
|
200
|
+
*
|
|
201
|
+
* Overwrites the oldest entry when full. `toArray()` returns the
|
|
202
|
+
* stored entries in chronological order (oldest → newest) which is
|
|
203
|
+
* the order the debug overlay and time-travel replayer expect.
|
|
204
|
+
*
|
|
205
|
+
* Default capacity is 500 — at ~200 bytes per event this caps
|
|
206
|
+
* in-memory history at ~100 KB per engine, which is negligible
|
|
207
|
+
* compared to any other memory cost the app already pays.
|
|
208
|
+
*/
|
|
209
|
+
declare class RingBuffer<T> {
|
|
210
|
+
readonly capacity: number;
|
|
211
|
+
private buffer;
|
|
212
|
+
private head;
|
|
213
|
+
private count;
|
|
214
|
+
constructor(capacity?: number);
|
|
215
|
+
push(item: T): void;
|
|
216
|
+
toArray(): T[];
|
|
217
|
+
clear(): void;
|
|
218
|
+
get size(): number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
declare const VERSION = "0.0.0";
|
|
222
|
+
|
|
223
|
+
export { CrashCounter, Experiment, ExperimentsConfig, RingBuffer, VERSION, assignDefault, assignRandom, assignStickyHash, assignVariant, assignWeighted, bucketUserId, canonicalStringify, deepFreeze, hash32, isKilled, isTimeGated, resolveMutex };
|