@factoredui/core 0.3.0 → 0.4.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/dist/cli/{init.cjs → factoredui.cjs} +118 -5
- package/dist/index.cjs +110 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -10
- package/dist/index.d.ts +39 -10
- package/dist/index.js +110 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/supabase/migrations/20250101000026_rls_ui_specs.sql +9 -0
- package/supabase/migrations/20250101000027_devices.sql +44 -0
package/dist/index.d.cts
CHANGED
|
@@ -13,6 +13,10 @@ interface CaptureAdapter {
|
|
|
13
13
|
loadSessionId(): string | null;
|
|
14
14
|
clearSessionId(): void;
|
|
15
15
|
registerUnloadHandler(onUnload: () => void): void;
|
|
16
|
+
/** Persist queued events to survive app restarts. JSON string of event array. */
|
|
17
|
+
persistQueue?(serialized: string): void;
|
|
18
|
+
/** Load previously persisted queue. Returns null if empty. */
|
|
19
|
+
loadQueue?(): string | null;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
type EventType = "click" | "scroll" | "error" | "navigation" | "impression" | "input" | "focus" | "blur" | "submit" | "resize" | "visibility" | "rage_click" | "dead_click" | "scroll_reversal";
|
|
@@ -309,20 +313,45 @@ interface KMeansResult {
|
|
|
309
313
|
*/
|
|
310
314
|
declare function kMeans(points: number[][], k: number, seed?: number): KMeansResult;
|
|
311
315
|
|
|
312
|
-
/**
|
|
313
|
-
* Flag evaluation: reads experiment assignments for the current user.
|
|
314
|
-
* If not yet assigned to a running experiment, assigns deterministically via hash bucketing.
|
|
315
|
-
* Optional platform parameter filters experiments to those targeting the current platform.
|
|
316
|
-
*/
|
|
317
|
-
declare function evaluateFlag(supabase: SupabaseClient, experimentName: string, platform?: Platform): Promise<ExperimentAssignment | null>;
|
|
318
|
-
|
|
319
316
|
type TargetingOperator = "gt" | "gte" | "lt" | "lte" | "eq";
|
|
320
|
-
interface
|
|
317
|
+
interface FactorTargetingRule {
|
|
318
|
+
type: "factor";
|
|
319
|
+
factor: string;
|
|
320
|
+
operator: TargetingOperator;
|
|
321
|
+
threshold: number;
|
|
322
|
+
}
|
|
323
|
+
type MetadataField = "os_name" | "os_version" | "manufacturer" | "model" | "app_version" | "app_build" | "platform";
|
|
324
|
+
type MetadataOperator = "eq" | "neq" | "contains" | "gte" | "lte";
|
|
325
|
+
interface MetadataTargetingRule {
|
|
326
|
+
type: "metadata";
|
|
327
|
+
field: MetadataField;
|
|
328
|
+
operator: MetadataOperator;
|
|
329
|
+
value: string;
|
|
330
|
+
}
|
|
331
|
+
/** Legacy format (factor-only) for backward compatibility with existing experiments */
|
|
332
|
+
interface LegacyTargetingRule {
|
|
321
333
|
factor: string;
|
|
322
334
|
operator: TargetingOperator;
|
|
323
335
|
threshold: number;
|
|
324
336
|
}
|
|
325
|
-
|
|
337
|
+
type TargetingRule = FactorTargetingRule | MetadataTargetingRule | LegacyTargetingRule;
|
|
338
|
+
interface DeviceMetadata {
|
|
339
|
+
os_name?: string;
|
|
340
|
+
os_version?: string;
|
|
341
|
+
manufacturer?: string;
|
|
342
|
+
model?: string;
|
|
343
|
+
app_version?: string;
|
|
344
|
+
app_build?: string;
|
|
345
|
+
platform?: string;
|
|
346
|
+
}
|
|
347
|
+
declare function evaluateTargeting(factors: Factor[], rules: TargetingRule[], deviceMetadata?: DeviceMetadata): boolean;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Flag evaluation: reads experiment assignments for the current user.
|
|
351
|
+
* If not yet assigned to a running experiment, assigns deterministically via hash bucketing.
|
|
352
|
+
* Optional platform parameter filters experiments to those targeting the current platform.
|
|
353
|
+
*/
|
|
354
|
+
declare function evaluateFlag(supabase: SupabaseClient, experimentName: string, platform?: Platform, deviceMetadata?: DeviceMetadata): Promise<ExperimentAssignment | null>;
|
|
326
355
|
|
|
327
356
|
interface VariantDefinition {
|
|
328
357
|
variant_key: string;
|
|
@@ -557,4 +586,4 @@ declare function generateEd25519Keypair(): Promise<{
|
|
|
557
586
|
privateKey: string;
|
|
558
587
|
}>;
|
|
559
588
|
|
|
560
|
-
export { type ActionHandler, type ActionRef, type ActionRegistry, type ButtonProps, type CaptureAdapter, type CaptureEvent, type CaptureHandle, type CardProps, type ChipProps, type ComponentFactorAggregate, type ComponentRegistry, type ComponentRenderer, type Config, type CreatedExperiment, type DataSourceCache, type DataSourceConfig, type DataSourceRegistry, type DividerProps, type EventType, type ExperimentAssignment, type ExperimentDefinition, type ExperimentSummaryFilters, type ExperimentSummaryRow, type Factor, type FactorDelta, type FactorSnapshot, type FactorTier, type FactorVerdict, type GovernanceAction, type GovernanceLogRow, type GovernanceVerdict, type GridProps, type IconProps, type ImageProps, type KMeansResult, type KVStorage, type LayoutProps, type ListProps, type LoadedSpec, type ModalProps, type Platform, RENDERER_VERSION, type ResolvedSources, type ScrollViewProps, type SelectProps, type Session, type SignatureVerifier, type SignedSpec, type SliderProps, type SpacerProps, type Spec, type SpecNode, type SpecNodeType, type SpecSigner, type SpecStorage, type SpecValue, type TabsProps, type TargetingOperator, type TargetingRule, type TextInputProps, type TextProps, type Threshold, type ToggleProps, type UserCluster, type VariantDefinition, type VariantResult, buildFactorDashboardSpec, componentFactorSource, concludeExperiment, createDataSourceCache, createEd25519Signer, createEd25519Verifier, createExperiment, createSpecStorage, createWebAdapter, devSignatureVerifier, dispatchAction, evaluateExperimentThresholds, evaluateFlag, evaluateTargeting, factorHistorySource, factorSource, generateEd25519Keypair, initCapture, isBindingRef, kMeans, loadSpec, logGovernanceVerdict, queryClusterMembers, queryComponentFactors, queryExperimentResults, queryExperimentSummaries, queryFactorDelta, queryFactorHistory, queryFactors, queryGovernanceLog, queryRecentGovernanceLog, queryUserCluster, resolveAllSources, resolveBinding, resolveComponentPath, resolveProps, resolveTextWithBindings, runGovernanceCheck, startExperiment, validateSpec };
|
|
589
|
+
export { type ActionHandler, type ActionRef, type ActionRegistry, type ButtonProps, type CaptureAdapter, type CaptureEvent, type CaptureHandle, type CardProps, type ChipProps, type ComponentFactorAggregate, type ComponentRegistry, type ComponentRenderer, type Config, type CreatedExperiment, type DataSourceCache, type DataSourceConfig, type DataSourceRegistry, type DeviceMetadata, type DividerProps, type EventType, type ExperimentAssignment, type ExperimentDefinition, type ExperimentSummaryFilters, type ExperimentSummaryRow, type Factor, type FactorDelta, type FactorSnapshot, type FactorTargetingRule, type FactorTier, type FactorVerdict, type GovernanceAction, type GovernanceLogRow, type GovernanceVerdict, type GridProps, type IconProps, type ImageProps, type KMeansResult, type KVStorage, type LayoutProps, type ListProps, type LoadedSpec, type MetadataField, type MetadataOperator, type MetadataTargetingRule, type ModalProps, type Platform, RENDERER_VERSION, type ResolvedSources, type ScrollViewProps, type SelectProps, type Session, type SignatureVerifier, type SignedSpec, type SliderProps, type SpacerProps, type Spec, type SpecNode, type SpecNodeType, type SpecSigner, type SpecStorage, type SpecValue, type TabsProps, type TargetingOperator, type TargetingRule, type TextInputProps, type TextProps, type Threshold, type ToggleProps, type UserCluster, type VariantDefinition, type VariantResult, buildFactorDashboardSpec, componentFactorSource, concludeExperiment, createDataSourceCache, createEd25519Signer, createEd25519Verifier, createExperiment, createSpecStorage, createWebAdapter, devSignatureVerifier, dispatchAction, evaluateExperimentThresholds, evaluateFlag, evaluateTargeting, factorHistorySource, factorSource, generateEd25519Keypair, initCapture, isBindingRef, kMeans, loadSpec, logGovernanceVerdict, queryClusterMembers, queryComponentFactors, queryExperimentResults, queryExperimentSummaries, queryFactorDelta, queryFactorHistory, queryFactors, queryGovernanceLog, queryRecentGovernanceLog, queryUserCluster, resolveAllSources, resolveBinding, resolveComponentPath, resolveProps, resolveTextWithBindings, runGovernanceCheck, startExperiment, validateSpec };
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ interface CaptureAdapter {
|
|
|
13
13
|
loadSessionId(): string | null;
|
|
14
14
|
clearSessionId(): void;
|
|
15
15
|
registerUnloadHandler(onUnload: () => void): void;
|
|
16
|
+
/** Persist queued events to survive app restarts. JSON string of event array. */
|
|
17
|
+
persistQueue?(serialized: string): void;
|
|
18
|
+
/** Load previously persisted queue. Returns null if empty. */
|
|
19
|
+
loadQueue?(): string | null;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
type EventType = "click" | "scroll" | "error" | "navigation" | "impression" | "input" | "focus" | "blur" | "submit" | "resize" | "visibility" | "rage_click" | "dead_click" | "scroll_reversal";
|
|
@@ -309,20 +313,45 @@ interface KMeansResult {
|
|
|
309
313
|
*/
|
|
310
314
|
declare function kMeans(points: number[][], k: number, seed?: number): KMeansResult;
|
|
311
315
|
|
|
312
|
-
/**
|
|
313
|
-
* Flag evaluation: reads experiment assignments for the current user.
|
|
314
|
-
* If not yet assigned to a running experiment, assigns deterministically via hash bucketing.
|
|
315
|
-
* Optional platform parameter filters experiments to those targeting the current platform.
|
|
316
|
-
*/
|
|
317
|
-
declare function evaluateFlag(supabase: SupabaseClient, experimentName: string, platform?: Platform): Promise<ExperimentAssignment | null>;
|
|
318
|
-
|
|
319
316
|
type TargetingOperator = "gt" | "gte" | "lt" | "lte" | "eq";
|
|
320
|
-
interface
|
|
317
|
+
interface FactorTargetingRule {
|
|
318
|
+
type: "factor";
|
|
319
|
+
factor: string;
|
|
320
|
+
operator: TargetingOperator;
|
|
321
|
+
threshold: number;
|
|
322
|
+
}
|
|
323
|
+
type MetadataField = "os_name" | "os_version" | "manufacturer" | "model" | "app_version" | "app_build" | "platform";
|
|
324
|
+
type MetadataOperator = "eq" | "neq" | "contains" | "gte" | "lte";
|
|
325
|
+
interface MetadataTargetingRule {
|
|
326
|
+
type: "metadata";
|
|
327
|
+
field: MetadataField;
|
|
328
|
+
operator: MetadataOperator;
|
|
329
|
+
value: string;
|
|
330
|
+
}
|
|
331
|
+
/** Legacy format (factor-only) for backward compatibility with existing experiments */
|
|
332
|
+
interface LegacyTargetingRule {
|
|
321
333
|
factor: string;
|
|
322
334
|
operator: TargetingOperator;
|
|
323
335
|
threshold: number;
|
|
324
336
|
}
|
|
325
|
-
|
|
337
|
+
type TargetingRule = FactorTargetingRule | MetadataTargetingRule | LegacyTargetingRule;
|
|
338
|
+
interface DeviceMetadata {
|
|
339
|
+
os_name?: string;
|
|
340
|
+
os_version?: string;
|
|
341
|
+
manufacturer?: string;
|
|
342
|
+
model?: string;
|
|
343
|
+
app_version?: string;
|
|
344
|
+
app_build?: string;
|
|
345
|
+
platform?: string;
|
|
346
|
+
}
|
|
347
|
+
declare function evaluateTargeting(factors: Factor[], rules: TargetingRule[], deviceMetadata?: DeviceMetadata): boolean;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Flag evaluation: reads experiment assignments for the current user.
|
|
351
|
+
* If not yet assigned to a running experiment, assigns deterministically via hash bucketing.
|
|
352
|
+
* Optional platform parameter filters experiments to those targeting the current platform.
|
|
353
|
+
*/
|
|
354
|
+
declare function evaluateFlag(supabase: SupabaseClient, experimentName: string, platform?: Platform, deviceMetadata?: DeviceMetadata): Promise<ExperimentAssignment | null>;
|
|
326
355
|
|
|
327
356
|
interface VariantDefinition {
|
|
328
357
|
variant_key: string;
|
|
@@ -557,4 +586,4 @@ declare function generateEd25519Keypair(): Promise<{
|
|
|
557
586
|
privateKey: string;
|
|
558
587
|
}>;
|
|
559
588
|
|
|
560
|
-
export { type ActionHandler, type ActionRef, type ActionRegistry, type ButtonProps, type CaptureAdapter, type CaptureEvent, type CaptureHandle, type CardProps, type ChipProps, type ComponentFactorAggregate, type ComponentRegistry, type ComponentRenderer, type Config, type CreatedExperiment, type DataSourceCache, type DataSourceConfig, type DataSourceRegistry, type DividerProps, type EventType, type ExperimentAssignment, type ExperimentDefinition, type ExperimentSummaryFilters, type ExperimentSummaryRow, type Factor, type FactorDelta, type FactorSnapshot, type FactorTier, type FactorVerdict, type GovernanceAction, type GovernanceLogRow, type GovernanceVerdict, type GridProps, type IconProps, type ImageProps, type KMeansResult, type KVStorage, type LayoutProps, type ListProps, type LoadedSpec, type ModalProps, type Platform, RENDERER_VERSION, type ResolvedSources, type ScrollViewProps, type SelectProps, type Session, type SignatureVerifier, type SignedSpec, type SliderProps, type SpacerProps, type Spec, type SpecNode, type SpecNodeType, type SpecSigner, type SpecStorage, type SpecValue, type TabsProps, type TargetingOperator, type TargetingRule, type TextInputProps, type TextProps, type Threshold, type ToggleProps, type UserCluster, type VariantDefinition, type VariantResult, buildFactorDashboardSpec, componentFactorSource, concludeExperiment, createDataSourceCache, createEd25519Signer, createEd25519Verifier, createExperiment, createSpecStorage, createWebAdapter, devSignatureVerifier, dispatchAction, evaluateExperimentThresholds, evaluateFlag, evaluateTargeting, factorHistorySource, factorSource, generateEd25519Keypair, initCapture, isBindingRef, kMeans, loadSpec, logGovernanceVerdict, queryClusterMembers, queryComponentFactors, queryExperimentResults, queryExperimentSummaries, queryFactorDelta, queryFactorHistory, queryFactors, queryGovernanceLog, queryRecentGovernanceLog, queryUserCluster, resolveAllSources, resolveBinding, resolveComponentPath, resolveProps, resolveTextWithBindings, runGovernanceCheck, startExperiment, validateSpec };
|
|
589
|
+
export { type ActionHandler, type ActionRef, type ActionRegistry, type ButtonProps, type CaptureAdapter, type CaptureEvent, type CaptureHandle, type CardProps, type ChipProps, type ComponentFactorAggregate, type ComponentRegistry, type ComponentRenderer, type Config, type CreatedExperiment, type DataSourceCache, type DataSourceConfig, type DataSourceRegistry, type DeviceMetadata, type DividerProps, type EventType, type ExperimentAssignment, type ExperimentDefinition, type ExperimentSummaryFilters, type ExperimentSummaryRow, type Factor, type FactorDelta, type FactorSnapshot, type FactorTargetingRule, type FactorTier, type FactorVerdict, type GovernanceAction, type GovernanceLogRow, type GovernanceVerdict, type GridProps, type IconProps, type ImageProps, type KMeansResult, type KVStorage, type LayoutProps, type ListProps, type LoadedSpec, type MetadataField, type MetadataOperator, type MetadataTargetingRule, type ModalProps, type Platform, RENDERER_VERSION, type ResolvedSources, type ScrollViewProps, type SelectProps, type Session, type SignatureVerifier, type SignedSpec, type SliderProps, type SpacerProps, type Spec, type SpecNode, type SpecNodeType, type SpecSigner, type SpecStorage, type SpecValue, type TabsProps, type TargetingOperator, type TargetingRule, type TextInputProps, type TextProps, type Threshold, type ToggleProps, type UserCluster, type VariantDefinition, type VariantResult, buildFactorDashboardSpec, componentFactorSource, concludeExperiment, createDataSourceCache, createEd25519Signer, createEd25519Verifier, createExperiment, createSpecStorage, createWebAdapter, devSignatureVerifier, dispatchAction, evaluateExperimentThresholds, evaluateFlag, evaluateTargeting, factorHistorySource, factorSource, generateEd25519Keypair, initCapture, isBindingRef, kMeans, loadSpec, logGovernanceVerdict, queryClusterMembers, queryComponentFactors, queryExperimentResults, queryExperimentSummaries, queryFactorDelta, queryFactorHistory, queryFactors, queryGovernanceLog, queryRecentGovernanceLog, queryUserCluster, resolveAllSources, resolveBinding, resolveComponentPath, resolveProps, resolveTextWithBindings, runGovernanceCheck, startExperiment, validateSpec };
|
package/dist/index.js
CHANGED
|
@@ -39,7 +39,7 @@ function createSessionManager(supabase, adapter, platform, timeoutMs = DEFAULT_T
|
|
|
39
39
|
// src/capture/writer.ts
|
|
40
40
|
var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
|
|
41
41
|
var DEFAULT_FLUSH_BATCH_SIZE = 50;
|
|
42
|
-
function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS, flushBatchSize = DEFAULT_FLUSH_BATCH_SIZE) {
|
|
42
|
+
function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS, flushBatchSize = DEFAULT_FLUSH_BATCH_SIZE, adapter) {
|
|
43
43
|
let queue = [];
|
|
44
44
|
let flushTimer = null;
|
|
45
45
|
let isFlushing = false;
|
|
@@ -63,15 +63,37 @@ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS
|
|
|
63
63
|
const { error } = await supabase.from("events").insert(batch);
|
|
64
64
|
if (error) {
|
|
65
65
|
queue.unshift(...batch);
|
|
66
|
+
persistQueueToAdapter();
|
|
66
67
|
console.error("factoredui: flush failed:", error.message);
|
|
67
68
|
}
|
|
68
69
|
} catch (err) {
|
|
69
70
|
queue.unshift(...batch);
|
|
71
|
+
persistQueueToAdapter();
|
|
70
72
|
console.error("factoredui: flush error:", err);
|
|
71
73
|
} finally {
|
|
72
74
|
isFlushing = false;
|
|
73
75
|
}
|
|
74
76
|
}
|
|
77
|
+
function persistQueueToAdapter() {
|
|
78
|
+
if (!adapter?.persistQueue || queue.length === 0) return;
|
|
79
|
+
try {
|
|
80
|
+
adapter.persistQueue(JSON.stringify(queue));
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function drainPersistedQueue() {
|
|
85
|
+
if (!adapter?.loadQueue) return;
|
|
86
|
+
try {
|
|
87
|
+
const serialized = adapter.loadQueue();
|
|
88
|
+
if (!serialized) return;
|
|
89
|
+
const persisted = JSON.parse(serialized);
|
|
90
|
+
if (persisted.length > 0) {
|
|
91
|
+
queue.unshift(...persisted);
|
|
92
|
+
adapter.persistQueue?.("");
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
}
|
|
75
97
|
function startAutoFlush() {
|
|
76
98
|
if (flushTimer) return;
|
|
77
99
|
flushTimer = setInterval(flush, flushIntervalMs);
|
|
@@ -82,7 +104,7 @@ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS
|
|
|
82
104
|
flushTimer = null;
|
|
83
105
|
}
|
|
84
106
|
}
|
|
85
|
-
return { enqueue, flush, startAutoFlush, stopAutoFlush };
|
|
107
|
+
return { enqueue, flush, startAutoFlush, stopAutoFlush, drainPersistedQueue };
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
// src/capture/path.ts
|
|
@@ -159,6 +181,7 @@ function detectScrollReversal(state, scrollY, now) {
|
|
|
159
181
|
var THROTTLE_INTERVAL_MS = 100;
|
|
160
182
|
var DEAD_CLICK_WAIT_MS = 1e3;
|
|
161
183
|
var SESSION_STORAGE_KEY = "factoredui:session_id";
|
|
184
|
+
var QUEUE_STORAGE_KEY = "factoredui:offline_queue";
|
|
162
185
|
function createWebAdapter() {
|
|
163
186
|
const handlers = /* @__PURE__ */ new Map();
|
|
164
187
|
const rageClickState = createRageClickState();
|
|
@@ -243,9 +266,28 @@ function createWebAdapter() {
|
|
|
243
266
|
storeSessionId,
|
|
244
267
|
loadSessionId,
|
|
245
268
|
clearSessionId,
|
|
246
|
-
registerUnloadHandler
|
|
269
|
+
registerUnloadHandler,
|
|
270
|
+
persistQueue,
|
|
271
|
+
loadQueue
|
|
247
272
|
};
|
|
248
273
|
}
|
|
274
|
+
function persistQueue(serialized) {
|
|
275
|
+
try {
|
|
276
|
+
if (!serialized || serialized === "") {
|
|
277
|
+
localStorage.removeItem(QUEUE_STORAGE_KEY);
|
|
278
|
+
} else {
|
|
279
|
+
localStorage.setItem(QUEUE_STORAGE_KEY, serialized);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function loadQueue() {
|
|
285
|
+
try {
|
|
286
|
+
return localStorage.getItem(QUEUE_STORAGE_KEY);
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
249
291
|
function collectSessionMetadata() {
|
|
250
292
|
if (typeof window === "undefined") return {};
|
|
251
293
|
return {
|
|
@@ -767,10 +809,10 @@ function createSeededRng(seed) {
|
|
|
767
809
|
}
|
|
768
810
|
|
|
769
811
|
// src/experiment/targeting.ts
|
|
770
|
-
function evaluateTargeting(factors, rules) {
|
|
812
|
+
function evaluateTargeting(factors, rules, deviceMetadata) {
|
|
771
813
|
if (rules.length === 0) return true;
|
|
772
814
|
const factorsByName = indexFactorsByName(factors);
|
|
773
|
-
return rules.every((rule) => evaluateRule(factorsByName, rule));
|
|
815
|
+
return rules.every((rule) => evaluateRule(factorsByName, rule, deviceMetadata));
|
|
774
816
|
}
|
|
775
817
|
function indexFactorsByName(factors) {
|
|
776
818
|
const indexed = /* @__PURE__ */ new Map();
|
|
@@ -779,12 +821,32 @@ function indexFactorsByName(factors) {
|
|
|
779
821
|
}
|
|
780
822
|
return indexed;
|
|
781
823
|
}
|
|
782
|
-
function
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
824
|
+
function isMetadataRule(rule) {
|
|
825
|
+
return "type" in rule && rule.type === "metadata";
|
|
826
|
+
}
|
|
827
|
+
function isFactorRule(rule) {
|
|
828
|
+
return !("type" in rule) || rule.type === "factor";
|
|
829
|
+
}
|
|
830
|
+
function evaluateRule(factorsByName, rule, deviceMetadata) {
|
|
831
|
+
if (isMetadataRule(rule)) {
|
|
832
|
+
return evaluateMetadataRule(rule, deviceMetadata);
|
|
833
|
+
}
|
|
834
|
+
if (isFactorRule(rule)) {
|
|
835
|
+
const threshold = "threshold" in rule ? rule.threshold : 0;
|
|
836
|
+
const factorName = rule.factor;
|
|
837
|
+
const value = factorsByName.get(factorName);
|
|
838
|
+
if (value === void 0) return false;
|
|
839
|
+
return compareNumeric(value, rule.operator, threshold);
|
|
840
|
+
}
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
function evaluateMetadataRule(rule, metadata) {
|
|
844
|
+
if (!metadata) return false;
|
|
845
|
+
const fieldValue = metadata[rule.field];
|
|
846
|
+
if (fieldValue === void 0 || fieldValue === null) return false;
|
|
847
|
+
return compareString(fieldValue, rule.operator, rule.value);
|
|
786
848
|
}
|
|
787
|
-
function
|
|
849
|
+
function compareNumeric(value, operator, threshold) {
|
|
788
850
|
switch (operator) {
|
|
789
851
|
case "gt":
|
|
790
852
|
return value > threshold;
|
|
@@ -798,9 +860,25 @@ function compareValue(value, operator, threshold) {
|
|
|
798
860
|
return value === threshold;
|
|
799
861
|
}
|
|
800
862
|
}
|
|
863
|
+
function compareString(value, operator, target) {
|
|
864
|
+
const lower = value.toLowerCase();
|
|
865
|
+
const targetLower = target.toLowerCase();
|
|
866
|
+
switch (operator) {
|
|
867
|
+
case "eq":
|
|
868
|
+
return lower === targetLower;
|
|
869
|
+
case "neq":
|
|
870
|
+
return lower !== targetLower;
|
|
871
|
+
case "contains":
|
|
872
|
+
return lower.includes(targetLower);
|
|
873
|
+
case "gte":
|
|
874
|
+
return lower >= targetLower;
|
|
875
|
+
case "lte":
|
|
876
|
+
return lower <= targetLower;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
801
879
|
|
|
802
880
|
// src/experiment/flags.ts
|
|
803
|
-
async function evaluateFlag(supabase, experimentName, platform) {
|
|
881
|
+
async function evaluateFlag(supabase, experimentName, platform, deviceMetadata) {
|
|
804
882
|
const userId = await resolveUserId2(supabase);
|
|
805
883
|
if (!userId) return null;
|
|
806
884
|
const existingAssignment = await fetchAssignment(supabase, userId, experimentName);
|
|
@@ -808,7 +886,7 @@ async function evaluateFlag(supabase, experimentName, platform) {
|
|
|
808
886
|
await recordExposure(supabase, userId, existingAssignment);
|
|
809
887
|
return existingAssignment;
|
|
810
888
|
}
|
|
811
|
-
const newAssignment = await assignToExperiment(supabase, userId, experimentName, platform);
|
|
889
|
+
const newAssignment = await assignToExperiment(supabase, userId, experimentName, platform, deviceMetadata);
|
|
812
890
|
if (newAssignment) {
|
|
813
891
|
await recordExposure(supabase, userId, newAssignment);
|
|
814
892
|
}
|
|
@@ -833,10 +911,17 @@ async function fetchAssignment(supabase, userId, experimentName) {
|
|
|
833
911
|
config: row.experiment_variants?.config ?? {}
|
|
834
912
|
};
|
|
835
913
|
}
|
|
836
|
-
async function assignToExperiment(supabase, userId, experimentName, platform) {
|
|
914
|
+
async function assignToExperiment(supabase, userId, experimentName, platform, deviceMetadata) {
|
|
837
915
|
const experiment = await fetchRunningExperiment(supabase, experimentName, platform);
|
|
838
916
|
if (!experiment) return null;
|
|
839
|
-
const
|
|
917
|
+
const hasConflict = await hasConflictingAssignment(
|
|
918
|
+
supabase,
|
|
919
|
+
userId,
|
|
920
|
+
experiment.component_path,
|
|
921
|
+
experiment.id
|
|
922
|
+
);
|
|
923
|
+
if (hasConflict) return null;
|
|
924
|
+
const isTargeted = await checkTargeting(supabase, userId, experiment, deviceMetadata);
|
|
840
925
|
if (!isTargeted) return null;
|
|
841
926
|
const variants = await fetchExperimentVariants(supabase, experiment.id);
|
|
842
927
|
if (variants.length === 0) return null;
|
|
@@ -854,6 +939,11 @@ async function assignToExperiment(supabase, userId, experimentName, platform) {
|
|
|
854
939
|
config: selectedVariant.config
|
|
855
940
|
};
|
|
856
941
|
}
|
|
942
|
+
async function hasConflictingAssignment(supabase, userId, componentPath, currentExperimentId) {
|
|
943
|
+
const { data, error } = await supabase.from("experiment_assignments").select("experiment_id, experiments!inner ( id, status, component_path )").eq("user_id", userId).eq("experiments.status", "running").eq("experiments.component_path", componentPath).neq("experiment_id", currentExperimentId).limit(1);
|
|
944
|
+
if (error) return false;
|
|
945
|
+
return (data?.length ?? 0) > 0;
|
|
946
|
+
}
|
|
857
947
|
async function fetchRunningExperiment(supabase, experimentName, platform) {
|
|
858
948
|
const { data, error } = await supabase.from("experiments").select("id, name, component_path, targeting_rules, platforms").eq("name", experimentName).eq("status", "running").maybeSingle();
|
|
859
949
|
if (error || !data) return null;
|
|
@@ -863,10 +953,10 @@ async function fetchRunningExperiment(supabase, experimentName, platform) {
|
|
|
863
953
|
}
|
|
864
954
|
return experiment;
|
|
865
955
|
}
|
|
866
|
-
async function checkTargeting(supabase, userId, experiment) {
|
|
956
|
+
async function checkTargeting(supabase, userId, experiment, deviceMetadata) {
|
|
867
957
|
if (!experiment.targeting_rules || experiment.targeting_rules.length === 0) return true;
|
|
868
958
|
const factors = await queryFactors(supabase, userId, experiment.component_path);
|
|
869
|
-
return evaluateTargeting(factors, experiment.targeting_rules);
|
|
959
|
+
return evaluateTargeting(factors, experiment.targeting_rules, deviceMetadata);
|
|
870
960
|
}
|
|
871
961
|
async function fetchExperimentVariants(supabase, experimentId) {
|
|
872
962
|
const { data, error } = await supabase.from("experiment_variants").select("variant_key, config, traffic_percentage").eq("experiment_id", experimentId).order("variant_key");
|
|
@@ -908,8 +998,11 @@ async function createExperiment(client, definition) {
|
|
|
908
998
|
return experiment;
|
|
909
999
|
}
|
|
910
1000
|
async function startExperiment(client, experimentId) {
|
|
911
|
-
const { error } = await client.from("experiments").update({ status: "running" }).eq("id", experimentId).eq("status", "draft");
|
|
1001
|
+
const { data, error } = await client.from("experiments").update({ status: "running" }).eq("id", experimentId).eq("status", "draft").select("id");
|
|
912
1002
|
if (error) throw new Error(`startExperiment failed: ${error.message}`);
|
|
1003
|
+
if (!data || data.length === 0) {
|
|
1004
|
+
throw new Error(`startExperiment: experiment ${experimentId} not found or not in draft status`);
|
|
1005
|
+
}
|
|
913
1006
|
}
|
|
914
1007
|
function validateDefinition(definition) {
|
|
915
1008
|
if (definition.variants.length < 2) {
|