@traffical/core 0.1.2

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.
Files changed (68) hide show
  1. package/dist/dedup/decision-dedup.d.ts +74 -0
  2. package/dist/dedup/decision-dedup.d.ts.map +1 -0
  3. package/dist/dedup/decision-dedup.js +132 -0
  4. package/dist/dedup/decision-dedup.js.map +1 -0
  5. package/dist/dedup/index.d.ts +5 -0
  6. package/dist/dedup/index.d.ts.map +1 -0
  7. package/dist/dedup/index.js +5 -0
  8. package/dist/dedup/index.js.map +1 -0
  9. package/dist/edge/client.d.ts +109 -0
  10. package/dist/edge/client.d.ts.map +1 -0
  11. package/dist/edge/client.js +154 -0
  12. package/dist/edge/client.js.map +1 -0
  13. package/dist/edge/index.d.ts +7 -0
  14. package/dist/edge/index.d.ts.map +1 -0
  15. package/dist/edge/index.js +7 -0
  16. package/dist/edge/index.js.map +1 -0
  17. package/dist/hashing/bucket.d.ts +56 -0
  18. package/dist/hashing/bucket.d.ts.map +1 -0
  19. package/dist/hashing/bucket.js +89 -0
  20. package/dist/hashing/bucket.js.map +1 -0
  21. package/dist/hashing/fnv1a.d.ts +17 -0
  22. package/dist/hashing/fnv1a.d.ts.map +1 -0
  23. package/dist/hashing/fnv1a.js +27 -0
  24. package/dist/hashing/fnv1a.js.map +1 -0
  25. package/dist/hashing/index.d.ts +8 -0
  26. package/dist/hashing/index.d.ts.map +1 -0
  27. package/dist/hashing/index.js +8 -0
  28. package/dist/hashing/index.js.map +1 -0
  29. package/dist/ids/index.d.ts +83 -0
  30. package/dist/ids/index.d.ts.map +1 -0
  31. package/dist/ids/index.js +165 -0
  32. package/dist/ids/index.js.map +1 -0
  33. package/dist/index.d.ts +20 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +32 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/resolution/conditions.d.ts +81 -0
  38. package/dist/resolution/conditions.d.ts.map +1 -0
  39. package/dist/resolution/conditions.js +197 -0
  40. package/dist/resolution/conditions.js.map +1 -0
  41. package/dist/resolution/engine.d.ts +54 -0
  42. package/dist/resolution/engine.d.ts.map +1 -0
  43. package/dist/resolution/engine.js +382 -0
  44. package/dist/resolution/engine.js.map +1 -0
  45. package/dist/resolution/index.d.ts +8 -0
  46. package/dist/resolution/index.d.ts.map +1 -0
  47. package/dist/resolution/index.js +10 -0
  48. package/dist/resolution/index.js.map +1 -0
  49. package/dist/types/index.d.ts +440 -0
  50. package/dist/types/index.d.ts.map +1 -0
  51. package/dist/types/index.js +8 -0
  52. package/dist/types/index.js.map +1 -0
  53. package/package.json +51 -0
  54. package/src/dedup/decision-dedup.ts +175 -0
  55. package/src/dedup/index.ts +6 -0
  56. package/src/edge/client.ts +256 -0
  57. package/src/edge/index.ts +16 -0
  58. package/src/hashing/bucket.ts +115 -0
  59. package/src/hashing/fnv1a.test.ts +87 -0
  60. package/src/hashing/fnv1a.ts +31 -0
  61. package/src/hashing/index.ts +15 -0
  62. package/src/ids/index.ts +221 -0
  63. package/src/index.ts +136 -0
  64. package/src/resolution/conditions.ts +253 -0
  65. package/src/resolution/engine.test.ts +242 -0
  66. package/src/resolution/engine.ts +480 -0
  67. package/src/resolution/index.ts +32 -0
  68. package/src/types/index.ts +508 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * DecisionDeduplicator - Pure decision deduplication logic.
3
+ *
4
+ * Tracks which user+assignment combinations have been seen to avoid
5
+ * sending duplicate decision events. This enables efficient decision
6
+ * tracking without overwhelming the event pipeline.
7
+ *
8
+ * Key differences from ExposureDeduplicator:
9
+ * - Pure in-memory (no I/O, no storage dependency)
10
+ * - Deduplicates on unitKey + assignment hash (not policy/variant)
11
+ * - Suitable for use in any JavaScript environment
12
+ */
13
+
14
+ import type { ParameterValue } from "../types/index.js";
15
+
16
+ const DEFAULT_TTL_MS = 3600_000; // 1 hour
17
+ const DEFAULT_MAX_ENTRIES = 10_000;
18
+ const CLEANUP_THRESHOLD = 0.2; // Clean when 20% of entries are expired
19
+
20
+ export interface DecisionDeduplicatorOptions {
21
+ /**
22
+ * Time-to-live for deduplication entries in milliseconds.
23
+ * After this time, the same decision can be tracked again.
24
+ * Default: 1 hour (3600000 ms)
25
+ */
26
+ ttlMs?: number;
27
+ /**
28
+ * Maximum number of entries to store.
29
+ * When exceeded, oldest entries are removed.
30
+ * Default: 10000
31
+ */
32
+ maxEntries?: number;
33
+ }
34
+
35
+ export class DecisionDeduplicator {
36
+ private _seen = new Map<string, number>(); // key -> timestamp
37
+ private readonly _ttlMs: number;
38
+ private readonly _maxEntries: number;
39
+ private _lastCleanup = Date.now();
40
+
41
+ constructor(options: DecisionDeduplicatorOptions = {}) {
42
+ this._ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
43
+ this._maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
44
+ }
45
+
46
+ /**
47
+ * Generate a stable hash for assignment values.
48
+ * Used to create a deduplication key from assignments.
49
+ */
50
+ static hashAssignments(assignments: Record<string, ParameterValue>): string {
51
+ // Sort keys for deterministic ordering
52
+ const sortedKeys = Object.keys(assignments).sort();
53
+ const parts: string[] = [];
54
+
55
+ for (const key of sortedKeys) {
56
+ const value = assignments[key];
57
+ // Simple string representation that's stable
58
+ const valueStr = typeof value === "object" ? JSON.stringify(value) : String(value);
59
+ parts.push(`${key}=${valueStr}`);
60
+ }
61
+
62
+ return parts.join("|");
63
+ }
64
+
65
+ /**
66
+ * Create a deduplication key from unitKey and assignment hash.
67
+ */
68
+ static createKey(unitKey: string, assignmentHash: string): string {
69
+ return `${unitKey}:${assignmentHash}`;
70
+ }
71
+
72
+ /**
73
+ * Check if this decision is new (not seen before within TTL).
74
+ * If new, marks it as seen.
75
+ *
76
+ * @param unitKey - The unit key (user identifier)
77
+ * @param assignmentHash - Hash of the assignments (from hashAssignments)
78
+ * @returns true if this is a new decision, false if duplicate
79
+ */
80
+ checkAndMark(unitKey: string, assignmentHash: string): boolean {
81
+ const key = DecisionDeduplicator.createKey(unitKey, assignmentHash);
82
+ const now = Date.now();
83
+ const lastSeen = this._seen.get(key);
84
+
85
+ // Check if we've seen this within TTL
86
+ if (lastSeen !== undefined && now - lastSeen < this._ttlMs) {
87
+ return false; // Duplicate
88
+ }
89
+
90
+ // Mark as seen
91
+ this._seen.set(key, now);
92
+
93
+ // Periodic cleanup
94
+ this._maybeCleanup(now);
95
+
96
+ return true; // New decision
97
+ }
98
+
99
+ /**
100
+ * Check if a decision would be considered new (without marking it).
101
+ */
102
+ wouldBeNew(unitKey: string, assignmentHash: string): boolean {
103
+ const key = DecisionDeduplicator.createKey(unitKey, assignmentHash);
104
+ const now = Date.now();
105
+ const lastSeen = this._seen.get(key);
106
+
107
+ if (lastSeen === undefined) {
108
+ return true;
109
+ }
110
+
111
+ return now - lastSeen >= this._ttlMs;
112
+ }
113
+
114
+ /**
115
+ * Clear all seen decisions.
116
+ */
117
+ clear(): void {
118
+ this._seen.clear();
119
+ }
120
+
121
+ /**
122
+ * Get the number of entries in the deduplication cache.
123
+ */
124
+ get size(): number {
125
+ return this._seen.size;
126
+ }
127
+
128
+ /**
129
+ * Perform cleanup of expired entries.
130
+ * Called periodically based on CLEANUP_THRESHOLD.
131
+ */
132
+ private _maybeCleanup(now: number): void {
133
+ // Only cleanup periodically, not on every call
134
+ const timeSinceCleanup = now - this._lastCleanup;
135
+ const shouldCleanup =
136
+ timeSinceCleanup > this._ttlMs * CLEANUP_THRESHOLD || this._seen.size > this._maxEntries;
137
+
138
+ if (!shouldCleanup) {
139
+ return;
140
+ }
141
+
142
+ this._lastCleanup = now;
143
+ this._cleanup(now);
144
+ }
145
+
146
+ /**
147
+ * Remove expired entries and enforce max size.
148
+ */
149
+ private _cleanup(now: number): void {
150
+ const expiredKeys: string[] = [];
151
+
152
+ // Find expired entries
153
+ for (const [key, timestamp] of this._seen.entries()) {
154
+ if (now - timestamp >= this._ttlMs) {
155
+ expiredKeys.push(key);
156
+ }
157
+ }
158
+
159
+ // Remove expired entries
160
+ for (const key of expiredKeys) {
161
+ this._seen.delete(key);
162
+ }
163
+
164
+ // If still over max, remove oldest entries
165
+ if (this._seen.size > this._maxEntries) {
166
+ const entries = Array.from(this._seen.entries()).sort((a, b) => a[1] - b[1]); // Sort by timestamp
167
+
168
+ const toRemove = entries.slice(0, this._seen.size - this._maxEntries);
169
+ for (const [key] of toRemove) {
170
+ this._seen.delete(key);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Deduplication utilities for event tracking.
3
+ */
4
+
5
+ export { DecisionDeduplicator, type DecisionDeduplicatorOptions } from "./decision-dedup.js";
6
+
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Edge Client
3
+ *
4
+ * Client for making per-entity decisions via the edge worker API.
5
+ * Used when policies have entityConfig.resolutionMode = "edge".
6
+ *
7
+ * The edge client provides:
8
+ * - Real-time entity state resolution (vs batched bundle updates)
9
+ * - Timeout handling with fallback to bundle
10
+ * - Request batching for multiple entities
11
+ */
12
+
13
+ import type { Id, Context } from "../types/index.js";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ /**
20
+ * Request to the edge /decide endpoint.
21
+ */
22
+ export interface EdgeDecideRequest {
23
+ /** Policy ID */
24
+ policyId: Id;
25
+ /** Entity ID (composite from entityKeys) */
26
+ entityId: string;
27
+ /** Unit key value for deterministic selection */
28
+ unitKeyValue: string;
29
+ /** Number of allocations (for dynamic allocations) */
30
+ allocationCount?: number;
31
+ /** Full context (for logging/debugging) */
32
+ context?: Context;
33
+ }
34
+
35
+ /**
36
+ * Response from the edge /decide endpoint.
37
+ */
38
+ export interface EdgeDecideResponse {
39
+ /** Selected allocation index */
40
+ allocationIndex: number;
41
+ /** Selected allocation name */
42
+ allocationName: string;
43
+ /** Weights used for selection */
44
+ weights: number[];
45
+ /** Whether this was a cold start (no entity state) */
46
+ coldStart: boolean;
47
+ /** Entity state version */
48
+ stateVersion?: string;
49
+ }
50
+
51
+ /**
52
+ * Batch request for multiple entity decisions.
53
+ */
54
+ export interface EdgeBatchDecideRequest {
55
+ /** Array of individual decide requests */
56
+ requests: EdgeDecideRequest[];
57
+ }
58
+
59
+ /**
60
+ * Batch response for multiple entity decisions.
61
+ */
62
+ export interface EdgeBatchDecideResponse {
63
+ /** Array of individual decide responses (same order as requests) */
64
+ responses: EdgeDecideResponse[];
65
+ }
66
+
67
+ /**
68
+ * Edge client configuration.
69
+ */
70
+ export interface EdgeClientConfig {
71
+ /** Base URL for the edge worker (e.g., "https://edge.traffical.io") */
72
+ baseUrl: string;
73
+ /** Organization ID */
74
+ orgId: Id;
75
+ /** Project ID */
76
+ projectId: Id;
77
+ /** Environment */
78
+ env: string;
79
+ /** API key for authentication */
80
+ apiKey: string;
81
+ /** Default timeout in milliseconds */
82
+ defaultTimeoutMs?: number;
83
+ }
84
+
85
+ // =============================================================================
86
+ // Edge Client
87
+ // =============================================================================
88
+
89
+ /**
90
+ * EdgeClient - makes per-entity decisions via the edge worker API.
91
+ */
92
+ export class EdgeClient {
93
+ private config: EdgeClientConfig;
94
+ private defaultTimeout: number;
95
+
96
+ constructor(config: EdgeClientConfig) {
97
+ this.config = config;
98
+ this.defaultTimeout = config.defaultTimeoutMs ?? 100;
99
+ }
100
+
101
+ /**
102
+ * Makes a single entity decision via the edge API.
103
+ *
104
+ * @param request - The decide request
105
+ * @param timeoutMs - Optional timeout override
106
+ * @returns The decide response, or null if request failed/timed out
107
+ */
108
+ async decide(
109
+ request: EdgeDecideRequest,
110
+ timeoutMs?: number
111
+ ): Promise<EdgeDecideResponse | null> {
112
+ const timeout = timeoutMs ?? this.defaultTimeout;
113
+
114
+ try {
115
+ const controller = new AbortController();
116
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
117
+
118
+ const url = `${this.config.baseUrl}/v1/decide/${request.policyId}`;
119
+ const response = await fetch(url, {
120
+ method: "POST",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ Authorization: `Bearer ${this.config.apiKey}`,
124
+ "X-Org-Id": this.config.orgId,
125
+ "X-Project-Id": this.config.projectId,
126
+ "X-Env": this.config.env,
127
+ },
128
+ body: JSON.stringify({
129
+ entityId: request.entityId,
130
+ unitKeyValue: request.unitKeyValue,
131
+ allocationCount: request.allocationCount,
132
+ context: request.context,
133
+ }),
134
+ signal: controller.signal,
135
+ });
136
+
137
+ clearTimeout(timeoutId);
138
+
139
+ if (!response.ok) {
140
+ console.warn(
141
+ `[Traffical] Edge decide failed: ${response.status} ${response.statusText}`
142
+ );
143
+ return null;
144
+ }
145
+
146
+ return (await response.json()) as EdgeDecideResponse;
147
+ } catch (error) {
148
+ if (error instanceof Error && error.name === "AbortError") {
149
+ console.warn(`[Traffical] Edge decide timed out after ${timeout}ms`);
150
+ } else {
151
+ console.warn(`[Traffical] Edge decide error:`, error);
152
+ }
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Makes multiple entity decisions in a single batch request.
159
+ *
160
+ * @param requests - Array of decide requests
161
+ * @param timeoutMs - Optional timeout override
162
+ * @returns Array of responses (null for failed requests)
163
+ */
164
+ async decideBatch(
165
+ requests: EdgeDecideRequest[],
166
+ timeoutMs?: number
167
+ ): Promise<(EdgeDecideResponse | null)[]> {
168
+ if (requests.length === 0) return [];
169
+ if (requests.length === 1) {
170
+ const result = await this.decide(requests[0], timeoutMs);
171
+ return [result];
172
+ }
173
+
174
+ const timeout = timeoutMs ?? this.defaultTimeout;
175
+
176
+ try {
177
+ const controller = new AbortController();
178
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
179
+
180
+ const url = `${this.config.baseUrl}/v1/decide/batch`;
181
+ const response = await fetch(url, {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ Authorization: `Bearer ${this.config.apiKey}`,
186
+ "X-Org-Id": this.config.orgId,
187
+ "X-Project-Id": this.config.projectId,
188
+ "X-Env": this.config.env,
189
+ },
190
+ body: JSON.stringify({ requests }),
191
+ signal: controller.signal,
192
+ });
193
+
194
+ clearTimeout(timeoutId);
195
+
196
+ if (!response.ok) {
197
+ console.warn(
198
+ `[Traffical] Edge batch decide failed: ${response.status} ${response.statusText}`
199
+ );
200
+ return requests.map(() => null);
201
+ }
202
+
203
+ const data = (await response.json()) as EdgeBatchDecideResponse;
204
+ return data.responses;
205
+ } catch (error) {
206
+ if (error instanceof Error && error.name === "AbortError") {
207
+ console.warn(`[Traffical] Edge batch decide timed out after ${timeout}ms`);
208
+ } else {
209
+ console.warn(`[Traffical] Edge batch decide error:`, error);
210
+ }
211
+ return requests.map(() => null);
212
+ }
213
+ }
214
+ }
215
+
216
+ // =============================================================================
217
+ // Utility Functions
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Creates an edge decide request from policy and context.
222
+ *
223
+ * @param policyId - The policy ID
224
+ * @param entityKeys - Array of context keys that identify the entity
225
+ * @param context - The evaluation context
226
+ * @param unitKeyValue - The unit key value
227
+ * @param allocationCount - Number of allocations (for dynamic)
228
+ * @returns The decide request, or null if entity ID cannot be built
229
+ */
230
+ export function createEdgeDecideRequest(
231
+ policyId: Id,
232
+ entityKeys: string[],
233
+ context: Context,
234
+ unitKeyValue: string,
235
+ allocationCount?: number
236
+ ): EdgeDecideRequest | null {
237
+ // Build entity ID from context
238
+ const parts: string[] = [];
239
+ for (const key of entityKeys) {
240
+ const value = context[key];
241
+ if (value === undefined || value === null) {
242
+ return null;
243
+ }
244
+ parts.push(String(value));
245
+ }
246
+ const entityId = parts.join("_");
247
+
248
+ return {
249
+ policyId,
250
+ entityId,
251
+ unitKeyValue,
252
+ allocationCount,
253
+ context,
254
+ };
255
+ }
256
+
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Edge Client Module
3
+ *
4
+ * Exports for making per-entity decisions via the edge worker API.
5
+ */
6
+
7
+ export {
8
+ EdgeClient,
9
+ createEdgeDecideRequest,
10
+ type EdgeClientConfig,
11
+ type EdgeDecideRequest,
12
+ type EdgeDecideResponse,
13
+ type EdgeBatchDecideRequest,
14
+ type EdgeBatchDecideResponse,
15
+ } from "./client.js";
16
+
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Bucket Computation
3
+ *
4
+ * Deterministic bucket assignment for traffic splitting.
5
+ * The bucket is computed as: hash(unitKeyValue + ":" + layerId) % bucketCount
6
+ *
7
+ * This ensures:
8
+ * - Same user always gets same bucket for a given layer
9
+ * - Different layers can have independent bucketing (orthogonality)
10
+ * - Deterministic results across SDK and server
11
+ */
12
+
13
+ import { fnv1a } from "./fnv1a.js";
14
+
15
+ /**
16
+ * Computes the bucket for a given unit and layer.
17
+ *
18
+ * @param unitKeyValue - The value of the unit key (e.g., userId value)
19
+ * @param layerId - The layer ID for orthogonal bucketing
20
+ * @param bucketCount - Total number of buckets (e.g., 1000)
21
+ * @returns Bucket number in range [0, bucketCount - 1]
22
+ */
23
+ export function computeBucket(
24
+ unitKeyValue: string,
25
+ layerId: string,
26
+ bucketCount: number
27
+ ): number {
28
+ // Concatenate unit key and layer ID with separator
29
+ const input = `${unitKeyValue}:${layerId}`;
30
+
31
+ // Use FNV-1a for consistent hashing
32
+ const hash = fnv1a(input);
33
+
34
+ // Map to bucket range
35
+ return hash % bucketCount;
36
+ }
37
+
38
+ /**
39
+ * Checks if a bucket falls within a range.
40
+ *
41
+ * @param bucket - The computed bucket
42
+ * @param range - [start, end] inclusive range
43
+ * @returns True if bucket is in range
44
+ */
45
+ export function isInBucketRange(
46
+ bucket: number,
47
+ range: [number, number]
48
+ ): boolean {
49
+ return bucket >= range[0] && bucket <= range[1];
50
+ }
51
+
52
+ /**
53
+ * Finds which allocation matches a given bucket.
54
+ *
55
+ * @param bucket - The computed bucket
56
+ * @param allocations - Array of allocations with bucket ranges
57
+ * @returns The matching allocation, or null if none match
58
+ */
59
+ export function findMatchingAllocation<
60
+ T extends { bucketRange: [number, number] }
61
+ >(bucket: number, allocations: T[]): T | null {
62
+ for (const allocation of allocations) {
63
+ if (isInBucketRange(bucket, allocation.bucketRange)) {
64
+ return allocation;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Converts a percentage to a bucket range.
72
+ *
73
+ * @param percentage - Traffic percentage (0-100)
74
+ * @param bucketCount - Total buckets
75
+ * @param startBucket - Starting bucket (default 0)
76
+ * @returns [start, end] bucket range
77
+ */
78
+ export function percentageToBucketRange(
79
+ percentage: number,
80
+ bucketCount: number,
81
+ startBucket = 0
82
+ ): [number, number] {
83
+ const bucketsNeeded = Math.floor((percentage / 100) * bucketCount);
84
+ const endBucket = Math.min(startBucket + bucketsNeeded - 1, bucketCount - 1);
85
+ return [startBucket, endBucket];
86
+ }
87
+
88
+ /**
89
+ * Creates non-overlapping bucket ranges for multiple variants.
90
+ *
91
+ * @param percentages - Array of percentages that should sum to <= 100
92
+ * @param bucketCount - Total buckets
93
+ * @returns Array of [start, end] bucket ranges
94
+ */
95
+ export function createBucketRanges(
96
+ percentages: number[],
97
+ bucketCount: number
98
+ ): [number, number][] {
99
+ const ranges: [number, number][] = [];
100
+ let currentBucket = 0;
101
+
102
+ for (const percentage of percentages) {
103
+ if (percentage <= 0) continue;
104
+
105
+ const bucketsNeeded = Math.floor((percentage / 100) * bucketCount);
106
+ if (bucketsNeeded > 0) {
107
+ const endBucket = currentBucket + bucketsNeeded - 1;
108
+ ranges.push([currentBucket, endBucket]);
109
+ currentBucket = endBucket + 1;
110
+ }
111
+ }
112
+
113
+ return ranges;
114
+ }
115
+
@@ -0,0 +1,87 @@
1
+ /**
2
+ * FNV-1a Hash Tests
3
+ *
4
+ * Validates that the hash function produces consistent results.
5
+ */
6
+
7
+ import { describe, test, expect } from "bun:test";
8
+ import { fnv1a } from "./fnv1a.js";
9
+ import { computeBucket } from "./bucket.js";
10
+
11
+ describe("fnv1a", () => {
12
+ test("produces consistent hash for empty string", () => {
13
+ expect(fnv1a("")).toBe(2166136261);
14
+ });
15
+
16
+ test("produces consistent hash for simple strings", () => {
17
+ // These values are the canonical FNV-1a outputs
18
+ expect(fnv1a("a")).toBe(3826002220);
19
+ expect(fnv1a("test")).toBe(2949673445);
20
+ expect(fnv1a("hello")).toBe(1335831723);
21
+ });
22
+
23
+ test("produces different hashes for different inputs", () => {
24
+ const hash1 = fnv1a("user-abc");
25
+ const hash2 = fnv1a("user-xyz");
26
+ expect(hash1).not.toBe(hash2);
27
+ });
28
+ });
29
+
30
+ describe("computeBucket", () => {
31
+ test("produces consistent buckets for test vectors", () => {
32
+ // Test case: user-abc:layer_ui
33
+ const bucket1 = computeBucket("user-abc", "layer_ui", 1000);
34
+ expect(bucket1).toBe(551);
35
+
36
+ // Test case: user-abc:layer_pricing
37
+ const bucket2 = computeBucket("user-abc", "layer_pricing", 1000);
38
+ expect(bucket2).toBe(913);
39
+
40
+ // Test case: user-xyz:layer_ui
41
+ const bucket3 = computeBucket("user-xyz", "layer_ui", 1000);
42
+ expect(bucket3).toBe(214);
43
+
44
+ // Test case: user-xyz:layer_pricing
45
+ const bucket4 = computeBucket("user-xyz", "layer_pricing", 1000);
46
+ expect(bucket4).toBe(42);
47
+
48
+ // Test case: user-123:layer_ui
49
+ const bucket5 = computeBucket("user-123", "layer_ui", 1000);
50
+ expect(bucket5).toBe(871);
51
+
52
+ // Test case: user-123:layer_pricing
53
+ const bucket6 = computeBucket("user-123", "layer_pricing", 1000);
54
+ expect(bucket6).toBe(177);
55
+ });
56
+
57
+ test("bucket is always in valid range", () => {
58
+ const bucketCount = 1000;
59
+ const testInputs = [
60
+ "user-1",
61
+ "user-2",
62
+ "user-abc",
63
+ "user-xyz",
64
+ "test-user-with-long-id-12345",
65
+ ];
66
+
67
+ for (const userId of testInputs) {
68
+ const bucket = computeBucket(userId, "test_layer", bucketCount);
69
+ expect(bucket).toBeGreaterThanOrEqual(0);
70
+ expect(bucket).toBeLessThan(bucketCount);
71
+ }
72
+ });
73
+
74
+ test("same input produces same bucket", () => {
75
+ const bucket1 = computeBucket("user-abc", "layer_1", 1000);
76
+ const bucket2 = computeBucket("user-abc", "layer_1", 1000);
77
+ expect(bucket1).toBe(bucket2);
78
+ });
79
+
80
+ test("different layers produce different buckets (orthogonality)", () => {
81
+ const bucket1 = computeBucket("user-abc", "layer_1", 1000);
82
+ const bucket2 = computeBucket("user-abc", "layer_2", 1000);
83
+ // While not guaranteed to be different, they should be independent
84
+ // This test just ensures the layer ID is included in the hash
85
+ expect(fnv1a("user-abc:layer_1")).not.toBe(fnv1a("user-abc:layer_2"));
86
+ });
87
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * FNV-1a Hash Function
3
+ *
4
+ * A simple, fast hash function with good distribution properties.
5
+ * Used by Google's experimentation system for bucket assignment.
6
+ *
7
+ * This implementation produces consistent results across all platforms
8
+ * and is the canonical hash for Traffical SDKs.
9
+ */
10
+
11
+ const FNV_OFFSET_BASIS = 2166136261;
12
+ const FNV_PRIME = 16777619;
13
+
14
+ /**
15
+ * Computes the FNV-1a hash of a string.
16
+ *
17
+ * @param input - The string to hash
18
+ * @returns Unsigned 32-bit integer hash
19
+ */
20
+ export function fnv1a(input: string): number {
21
+ let hash = FNV_OFFSET_BASIS;
22
+
23
+ for (let i = 0; i < input.length; i++) {
24
+ hash ^= input.charCodeAt(i);
25
+ hash = Math.imul(hash, FNV_PRIME);
26
+ }
27
+
28
+ // Convert to unsigned 32-bit integer
29
+ return hash >>> 0;
30
+ }
31
+
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Hashing Module
3
+ *
4
+ * Exports all hashing-related functions for deterministic bucket assignment.
5
+ */
6
+
7
+ export { fnv1a } from "./fnv1a.js";
8
+ export {
9
+ computeBucket,
10
+ isInBucketRange,
11
+ findMatchingAllocation,
12
+ percentageToBucketRange,
13
+ createBucketRanges,
14
+ } from "./bucket.js";
15
+