@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.
- package/dist/dedup/decision-dedup.d.ts +74 -0
- package/dist/dedup/decision-dedup.d.ts.map +1 -0
- package/dist/dedup/decision-dedup.js +132 -0
- package/dist/dedup/decision-dedup.js.map +1 -0
- package/dist/dedup/index.d.ts +5 -0
- package/dist/dedup/index.d.ts.map +1 -0
- package/dist/dedup/index.js +5 -0
- package/dist/dedup/index.js.map +1 -0
- package/dist/edge/client.d.ts +109 -0
- package/dist/edge/client.d.ts.map +1 -0
- package/dist/edge/client.js +154 -0
- package/dist/edge/client.js.map +1 -0
- package/dist/edge/index.d.ts +7 -0
- package/dist/edge/index.d.ts.map +1 -0
- package/dist/edge/index.js +7 -0
- package/dist/edge/index.js.map +1 -0
- package/dist/hashing/bucket.d.ts +56 -0
- package/dist/hashing/bucket.d.ts.map +1 -0
- package/dist/hashing/bucket.js +89 -0
- package/dist/hashing/bucket.js.map +1 -0
- package/dist/hashing/fnv1a.d.ts +17 -0
- package/dist/hashing/fnv1a.d.ts.map +1 -0
- package/dist/hashing/fnv1a.js +27 -0
- package/dist/hashing/fnv1a.js.map +1 -0
- package/dist/hashing/index.d.ts +8 -0
- package/dist/hashing/index.d.ts.map +1 -0
- package/dist/hashing/index.js +8 -0
- package/dist/hashing/index.js.map +1 -0
- package/dist/ids/index.d.ts +83 -0
- package/dist/ids/index.d.ts.map +1 -0
- package/dist/ids/index.js +165 -0
- package/dist/ids/index.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/resolution/conditions.d.ts +81 -0
- package/dist/resolution/conditions.d.ts.map +1 -0
- package/dist/resolution/conditions.js +197 -0
- package/dist/resolution/conditions.js.map +1 -0
- package/dist/resolution/engine.d.ts +54 -0
- package/dist/resolution/engine.d.ts.map +1 -0
- package/dist/resolution/engine.js +382 -0
- package/dist/resolution/engine.js.map +1 -0
- package/dist/resolution/index.d.ts +8 -0
- package/dist/resolution/index.d.ts.map +1 -0
- package/dist/resolution/index.js +10 -0
- package/dist/resolution/index.js.map +1 -0
- package/dist/types/index.d.ts +440 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +51 -0
- package/src/dedup/decision-dedup.ts +175 -0
- package/src/dedup/index.ts +6 -0
- package/src/edge/client.ts +256 -0
- package/src/edge/index.ts +16 -0
- package/src/hashing/bucket.ts +115 -0
- package/src/hashing/fnv1a.test.ts +87 -0
- package/src/hashing/fnv1a.ts +31 -0
- package/src/hashing/index.ts +15 -0
- package/src/ids/index.ts +221 -0
- package/src/index.ts +136 -0
- package/src/resolution/conditions.ts +253 -0
- package/src/resolution/engine.test.ts +242 -0
- package/src/resolution/engine.ts +480 -0
- package/src/resolution/index.ts +32 -0
- 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,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
|
+
|