@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/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 TargetingRule {
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
- declare function evaluateTargeting(factors: Factor[], rules: TargetingRule[]): boolean;
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 TargetingRule {
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
- declare function evaluateTargeting(factors: Factor[], rules: TargetingRule[]): boolean;
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 evaluateRule(factorsByName, rule) {
783
- const value = factorsByName.get(rule.factor);
784
- if (value === void 0) return false;
785
- return compareValue(value, rule.operator, rule.threshold);
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 compareValue(value, operator, threshold) {
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 isTargeted = await checkTargeting(supabase, userId, experiment);
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) {