@factoredui/core 0.4.1 → 0.5.1
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/README.md +1 -1
- package/dist/index.cjs +127 -275
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +298 -201
- package/dist/index.d.ts +298 -201
- package/dist/index.js +123 -275
- package/dist/index.js.map +1 -1
- package/package.json +3 -11
- package/dist/cli/factoredui.cjs +0 -229
- package/supabase/migrations/20250101000001_schema_and_extensions.sql +0 -52
- package/supabase/migrations/20250101000002_sessions.sql +0 -20
- package/supabase/migrations/20250101000003_events.sql +0 -30
- package/supabase/migrations/20250101000004_factors.sql +0 -52
- package/supabase/migrations/20250101000005_vectors_and_clusters.sql +0 -32
- package/supabase/migrations/20250101000006_experiments.sql +0 -85
- package/supabase/migrations/20250101000007_variant_configs.sql +0 -19
- package/supabase/migrations/20250101000008_thresholds.sql +0 -22
- package/supabase/migrations/20250101000009_materialized_views.sql +0 -272
- package/supabase/migrations/20250101000010_rls_policies.sql +0 -69
- package/supabase/migrations/20250101000011_cron_jobs.sql +0 -99
- package/supabase/migrations/20250101000012_assignment_insert_grant.sql +0 -7
- package/supabase/migrations/20250101000013_assignment_variant_fk.sql +0 -7
- package/supabase/migrations/20250101000014_refresh_factor_views_fn.sql +0 -24
- package/supabase/migrations/20250101000015_structural_factors.sql +0 -123
- package/supabase/migrations/20250101000016_fix_cron_refresh.sql +0 -11
- package/supabase/migrations/20250101000017_targeting_rules.sql +0 -9
- package/supabase/migrations/20250101000018_governance_view.sql +0 -39
- package/supabase/migrations/20250101000019_bulk_factor_deltas.sql +0 -60
- package/supabase/migrations/20250101000020_governance_log.sql +0 -45
- package/supabase/migrations/20250101000021_governance_cron.sql +0 -170
- package/supabase/migrations/20250101000022_governance_trigger.sql +0 -217
- package/supabase/migrations/20250101000023_realtime_publication.sql +0 -9
- package/supabase/migrations/20250101000024_platform_dimension.sql +0 -18
- package/supabase/migrations/20250101000025_ui_specs.sql +0 -55
- package/supabase/migrations/20250101000026_rls_ui_specs.sql +0 -9
- package/supabase/migrations/20250101000027_devices.sql +0 -44
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Capture user interactions, compute behavioral factors, run experiments, and render server-driven UI. Pure TypeScript — no framework dependencies.
|
|
4
4
|
|
|
5
|
-
Part of the [FactoredUI](https://github.com/
|
|
5
|
+
Part of the [FactoredUI](https://github.com/jjrasche/factoredui) monorepo.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
package/dist/index.cjs
CHANGED
|
@@ -27,7 +27,9 @@ __export(src_exports, {
|
|
|
27
27
|
createDataSourceCache: () => createDataSourceCache,
|
|
28
28
|
createEd25519Signer: () => createEd25519Signer,
|
|
29
29
|
createEd25519Verifier: () => createEd25519Verifier,
|
|
30
|
+
createEventWriter: () => createEventWriter,
|
|
30
31
|
createExperiment: () => createExperiment,
|
|
32
|
+
createSessionManager: () => createSessionManager,
|
|
31
33
|
createSpecStorage: () => createSpecStorage,
|
|
32
34
|
createWebAdapter: () => createWebAdapter,
|
|
33
35
|
devSignatureVerifier: () => devSignatureVerifier,
|
|
@@ -47,10 +49,12 @@ __export(src_exports, {
|
|
|
47
49
|
queryComponentFactors: () => queryComponentFactors,
|
|
48
50
|
queryExperimentResults: () => queryExperimentResults,
|
|
49
51
|
queryExperimentSummaries: () => queryExperimentSummaries,
|
|
52
|
+
queryExperimentSummary: () => queryExperimentSummary,
|
|
50
53
|
queryFactorDelta: () => queryFactorDelta,
|
|
51
54
|
queryFactorHistory: () => queryFactorHistory,
|
|
52
55
|
queryFactors: () => queryFactors,
|
|
53
56
|
queryGovernanceLog: () => queryGovernanceLog,
|
|
57
|
+
queryGovernanceLogByVerdict: () => queryGovernanceLogByVerdict,
|
|
54
58
|
queryRecentGovernanceLog: () => queryRecentGovernanceLog,
|
|
55
59
|
queryUserCluster: () => queryUserCluster,
|
|
56
60
|
resolveAllSources: () => resolveAllSources,
|
|
@@ -66,7 +70,7 @@ module.exports = __toCommonJS(src_exports);
|
|
|
66
70
|
|
|
67
71
|
// src/capture/session.ts
|
|
68
72
|
var DEFAULT_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
69
|
-
function createSessionManager(
|
|
73
|
+
function createSessionManager(store, adapter, platform, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
70
74
|
let currentSessionId = adapter.loadSessionId();
|
|
71
75
|
let lastActivityAt = Date.now();
|
|
72
76
|
function isSessionExpired() {
|
|
@@ -77,9 +81,8 @@ function createSessionManager(supabase, adapter, platform, timeoutMs = DEFAULT_T
|
|
|
77
81
|
...adapter.collectSessionMetadata(),
|
|
78
82
|
platform
|
|
79
83
|
};
|
|
80
|
-
const {
|
|
81
|
-
|
|
82
|
-
return data.id;
|
|
84
|
+
const { id } = await store.insertSession(userId, metadata);
|
|
85
|
+
return id;
|
|
83
86
|
}
|
|
84
87
|
async function ensureSession(userId) {
|
|
85
88
|
lastActivityAt = Date.now();
|
|
@@ -92,7 +95,7 @@ function createSessionManager(supabase, adapter, platform, timeoutMs = DEFAULT_T
|
|
|
92
95
|
}
|
|
93
96
|
async function endSession() {
|
|
94
97
|
if (!currentSessionId) return;
|
|
95
|
-
await
|
|
98
|
+
await store.endSession(currentSessionId);
|
|
96
99
|
currentSessionId = null;
|
|
97
100
|
adapter.clearSessionId();
|
|
98
101
|
}
|
|
@@ -105,7 +108,7 @@ function createSessionManager(supabase, adapter, platform, timeoutMs = DEFAULT_T
|
|
|
105
108
|
// src/capture/writer.ts
|
|
106
109
|
var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
|
|
107
110
|
var DEFAULT_FLUSH_BATCH_SIZE = 50;
|
|
108
|
-
function createEventWriter(
|
|
111
|
+
function createEventWriter(store, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS, flushBatchSize = DEFAULT_FLUSH_BATCH_SIZE, adapter) {
|
|
109
112
|
let queue = [];
|
|
110
113
|
let flushTimer = null;
|
|
111
114
|
let isFlushing = false;
|
|
@@ -126,12 +129,7 @@ function createEventWriter(supabase, flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS
|
|
|
126
129
|
isFlushing = true;
|
|
127
130
|
const batch = queue.splice(0, flushBatchSize);
|
|
128
131
|
try {
|
|
129
|
-
|
|
130
|
-
if (error) {
|
|
131
|
-
queue.unshift(...batch);
|
|
132
|
-
persistQueueToAdapter();
|
|
133
|
-
console.error("factoredui: flush failed:", error.message);
|
|
134
|
-
}
|
|
132
|
+
await store.insertEvents(batch);
|
|
135
133
|
} catch (err) {
|
|
136
134
|
queue.unshift(...batch);
|
|
137
135
|
persistQueueToAdapter();
|
|
@@ -513,18 +511,18 @@ function initCapture(config) {
|
|
|
513
511
|
const adapter = config.adapter ?? createWebAdapter();
|
|
514
512
|
const platform = config.platform ?? "web";
|
|
515
513
|
const sessionManager = createSessionManager(
|
|
516
|
-
config.
|
|
514
|
+
config.store,
|
|
517
515
|
adapter,
|
|
518
516
|
platform,
|
|
519
517
|
config.sessionTimeoutMs
|
|
520
518
|
);
|
|
521
519
|
const writer = createEventWriter(
|
|
522
|
-
config.
|
|
520
|
+
config.store,
|
|
523
521
|
config.flushIntervalMs,
|
|
524
522
|
config.flushBatchSize
|
|
525
523
|
);
|
|
526
524
|
const enqueueEvent = createEventEnqueuer(
|
|
527
|
-
config.
|
|
525
|
+
config.store,
|
|
528
526
|
sessionManager,
|
|
529
527
|
writer
|
|
530
528
|
);
|
|
@@ -553,10 +551,10 @@ function initCapture(config) {
|
|
|
553
551
|
})
|
|
554
552
|
};
|
|
555
553
|
}
|
|
556
|
-
function createEventEnqueuer(
|
|
554
|
+
function createEventEnqueuer(store, sessionManager, writer) {
|
|
557
555
|
let cachedUserId = null;
|
|
558
556
|
return (event) => {
|
|
559
|
-
resolveUserId(
|
|
557
|
+
resolveUserId(store, cachedUserId).then((userId) => {
|
|
560
558
|
cachedUserId = userId;
|
|
561
559
|
return sessionManager.ensureSession(userId).then((sessionId) => {
|
|
562
560
|
writer.enqueue(sessionId, userId, event);
|
|
@@ -566,43 +564,33 @@ function createEventEnqueuer(supabase, sessionManager, writer) {
|
|
|
566
564
|
});
|
|
567
565
|
};
|
|
568
566
|
}
|
|
569
|
-
async function resolveUserId(
|
|
567
|
+
async function resolveUserId(store, cachedUserId) {
|
|
570
568
|
if (cachedUserId) return cachedUserId;
|
|
571
|
-
const
|
|
572
|
-
if (!
|
|
573
|
-
return
|
|
569
|
+
const userId = await store.getCurrentUserId();
|
|
570
|
+
if (!userId) throw new Error("factoredui: user not authenticated");
|
|
571
|
+
return userId;
|
|
574
572
|
}
|
|
575
573
|
|
|
576
574
|
// src/factors/query.ts
|
|
577
|
-
async function queryFactors(
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
async function queryComponentFactors(client, componentPath) {
|
|
583
|
-
const { data, error } = await client.from("v_component_factors_agg").select(
|
|
584
|
-
"component_path, factor_name, factor_tier, user_count, avg_value, median_value, p95_value, min_value, max_value, stddev_value"
|
|
585
|
-
).eq("component_path", componentPath);
|
|
586
|
-
if (error) throw new Error(`queryComponentFactors failed: ${error.message}`);
|
|
587
|
-
return data;
|
|
575
|
+
async function queryFactors(store, userId, componentPath) {
|
|
576
|
+
return store.queryFactors(userId, componentPath);
|
|
577
|
+
}
|
|
578
|
+
async function queryComponentFactors(store, componentPath) {
|
|
579
|
+
return store.queryComponentFactors(componentPath);
|
|
588
580
|
}
|
|
589
581
|
|
|
590
582
|
// src/factors/snapshots.ts
|
|
591
|
-
async function queryFactorHistory(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
async function queryFactorDelta(client, userId, componentPath, factorName, before, after) {
|
|
597
|
-
const beforeSnapshot = await findClosestSnapshot(
|
|
598
|
-
client,
|
|
583
|
+
async function queryFactorHistory(store, userId, componentPath, factorName, since) {
|
|
584
|
+
return store.queryFactorHistory(userId, componentPath, factorName, since);
|
|
585
|
+
}
|
|
586
|
+
async function queryFactorDelta(store, userId, componentPath, factorName, before, after) {
|
|
587
|
+
const beforeSnapshot = await store.findClosestSnapshot(
|
|
599
588
|
userId,
|
|
600
589
|
componentPath,
|
|
601
590
|
factorName,
|
|
602
591
|
before
|
|
603
592
|
);
|
|
604
|
-
const afterSnapshot = await findClosestSnapshot(
|
|
605
|
-
client,
|
|
593
|
+
const afterSnapshot = await store.findClosestSnapshot(
|
|
606
594
|
userId,
|
|
607
595
|
componentPath,
|
|
608
596
|
factorName,
|
|
@@ -616,28 +604,23 @@ async function queryFactorDelta(client, userId, componentPath, factorName, befor
|
|
|
616
604
|
delta: afterSnapshot.value - beforeSnapshot.value
|
|
617
605
|
};
|
|
618
606
|
}
|
|
619
|
-
async function findClosestSnapshot(client, userId, componentPath, factorName, targetDate) {
|
|
620
|
-
const { data, error } = await client.from("factor_snapshots").select("factor_name, factor_tier, value, snapshot_at").eq("user_id", userId).eq("component_path", componentPath).eq("factor_name", factorName).lte("snapshot_at", targetDate.toISOString()).order("snapshot_at", { ascending: false }).limit(1).maybeSingle();
|
|
621
|
-
if (error) throw new Error(`findClosestSnapshot failed: ${error.message}`);
|
|
622
|
-
return data;
|
|
623
|
-
}
|
|
624
607
|
|
|
625
608
|
// src/factors/data-sources.ts
|
|
626
|
-
function factorSource(
|
|
609
|
+
function factorSource(store, userId, componentPath) {
|
|
627
610
|
return {
|
|
628
|
-
fetch: () => queryFactors(
|
|
611
|
+
fetch: () => queryFactors(store, userId, componentPath),
|
|
629
612
|
cache: "local"
|
|
630
613
|
};
|
|
631
614
|
}
|
|
632
|
-
function componentFactorSource(
|
|
615
|
+
function componentFactorSource(store, componentPath) {
|
|
633
616
|
return {
|
|
634
|
-
fetch: () => queryComponentFactors(
|
|
617
|
+
fetch: () => queryComponentFactors(store, componentPath),
|
|
635
618
|
cache: "local"
|
|
636
619
|
};
|
|
637
620
|
}
|
|
638
|
-
function factorHistorySource(
|
|
621
|
+
function factorHistorySource(store, userId, componentPath, factorName, since) {
|
|
639
622
|
return {
|
|
640
|
-
fetch: () => queryFactorHistory(
|
|
623
|
+
fetch: () => queryFactorHistory(store, userId, componentPath, factorName, since),
|
|
641
624
|
cache: "local"
|
|
642
625
|
};
|
|
643
626
|
}
|
|
@@ -765,15 +748,11 @@ function buildStatColumn(id, label, valueRef) {
|
|
|
765
748
|
}
|
|
766
749
|
|
|
767
750
|
// src/factors/clustering.ts
|
|
768
|
-
async function queryUserCluster(
|
|
769
|
-
|
|
770
|
-
if (error) throw new Error(`queryUserCluster failed: ${error.message}`);
|
|
771
|
-
return data;
|
|
751
|
+
async function queryUserCluster(store, userId) {
|
|
752
|
+
return store.queryUserCluster(userId);
|
|
772
753
|
}
|
|
773
|
-
async function queryClusterMembers(
|
|
774
|
-
|
|
775
|
-
if (error) throw new Error(`queryClusterMembers failed: ${error.message}`);
|
|
776
|
-
return data ?? [];
|
|
754
|
+
async function queryClusterMembers(store, clusterId) {
|
|
755
|
+
return store.queryClusterMembers(clusterId);
|
|
777
756
|
}
|
|
778
757
|
var MAX_ITERATIONS = 50;
|
|
779
758
|
var CONVERGENCE_THRESHOLD = 1e-6;
|
|
@@ -944,91 +923,54 @@ function compareString(value, operator, target) {
|
|
|
944
923
|
}
|
|
945
924
|
|
|
946
925
|
// src/experiment/flags.ts
|
|
947
|
-
async function evaluateFlag(
|
|
948
|
-
const userId = await
|
|
926
|
+
async function evaluateFlag(store, experimentName, platform, deviceMetadata) {
|
|
927
|
+
const userId = await store.getCurrentUserId();
|
|
949
928
|
if (!userId) return null;
|
|
950
|
-
const existingAssignment = await
|
|
929
|
+
const existingAssignment = await store.getAssignment(userId, experimentName);
|
|
951
930
|
if (existingAssignment) {
|
|
952
|
-
await recordExposure(
|
|
931
|
+
await store.recordExposure(userId, existingAssignment.experiment_id, existingAssignment.variant_key);
|
|
953
932
|
return existingAssignment;
|
|
954
933
|
}
|
|
955
|
-
const newAssignment = await assignToExperiment(
|
|
934
|
+
const newAssignment = await assignToExperiment(store, userId, experimentName, platform, deviceMetadata);
|
|
956
935
|
if (newAssignment) {
|
|
957
|
-
await recordExposure(
|
|
936
|
+
await store.recordExposure(userId, newAssignment.experiment_id, newAssignment.variant_key);
|
|
958
937
|
}
|
|
959
938
|
return newAssignment;
|
|
960
939
|
}
|
|
961
|
-
async function
|
|
962
|
-
const
|
|
963
|
-
return user?.id ?? null;
|
|
964
|
-
}
|
|
965
|
-
async function fetchAssignment(supabase, userId, experimentName) {
|
|
966
|
-
const { data, error } = await supabase.from("experiment_assignments").select(`
|
|
967
|
-
experiment_id,
|
|
968
|
-
variant_key,
|
|
969
|
-
experiments!inner ( name, status ),
|
|
970
|
-
experiment_variants!inner ( config )
|
|
971
|
-
`).eq("user_id", userId).eq("experiments.name", experimentName).eq("experiments.status", "running").maybeSingle();
|
|
972
|
-
if (error || !data) return null;
|
|
973
|
-
const row = data;
|
|
974
|
-
return {
|
|
975
|
-
experiment_id: row.experiment_id,
|
|
976
|
-
variant_key: row.variant_key,
|
|
977
|
-
config: row.experiment_variants?.config ?? {}
|
|
978
|
-
};
|
|
979
|
-
}
|
|
980
|
-
async function assignToExperiment(supabase, userId, experimentName, platform, deviceMetadata) {
|
|
981
|
-
const experiment = await fetchRunningExperiment(supabase, experimentName, platform);
|
|
940
|
+
async function assignToExperiment(store, userId, experimentName, platform, deviceMetadata) {
|
|
941
|
+
const experiment = await store.getRunningExperiment(experimentName);
|
|
982
942
|
if (!experiment) return null;
|
|
983
|
-
|
|
984
|
-
|
|
943
|
+
if (platform && experiment.platforms.length > 0 && !experiment.platforms.includes(platform)) {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
const hasConflict = await store.hasConflictingAssignment(
|
|
985
947
|
userId,
|
|
986
948
|
experiment.component_path,
|
|
987
949
|
experiment.id
|
|
988
950
|
);
|
|
989
951
|
if (hasConflict) return null;
|
|
990
|
-
const isTargeted = await checkTargeting(
|
|
952
|
+
const isTargeted = await checkTargeting(store, userId, experiment, deviceMetadata);
|
|
991
953
|
if (!isTargeted) return null;
|
|
992
|
-
const variants = await
|
|
954
|
+
const variants = await store.getVariants(experiment.id);
|
|
993
955
|
if (variants.length === 0) return null;
|
|
994
956
|
const selectedVariant = selectVariantByHash(userId, experiment.id, variants);
|
|
995
957
|
if (!selectedVariant) return null;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
if (error) return null;
|
|
958
|
+
try {
|
|
959
|
+
await store.writeAssignment(userId, experiment.id, selectedVariant.variant_key);
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
1002
963
|
return {
|
|
1003
964
|
experiment_id: experiment.id,
|
|
1004
965
|
variant_key: selectedVariant.variant_key,
|
|
1005
966
|
config: selectedVariant.config
|
|
1006
967
|
};
|
|
1007
968
|
}
|
|
1008
|
-
async function
|
|
1009
|
-
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);
|
|
1010
|
-
if (error) return false;
|
|
1011
|
-
return (data?.length ?? 0) > 0;
|
|
1012
|
-
}
|
|
1013
|
-
async function fetchRunningExperiment(supabase, experimentName, platform) {
|
|
1014
|
-
const { data, error } = await supabase.from("experiments").select("id, name, component_path, targeting_rules, platforms").eq("name", experimentName).eq("status", "running").maybeSingle();
|
|
1015
|
-
if (error || !data) return null;
|
|
1016
|
-
const experiment = data;
|
|
1017
|
-
if (platform && experiment.platforms.length > 0 && !experiment.platforms.includes(platform)) {
|
|
1018
|
-
return null;
|
|
1019
|
-
}
|
|
1020
|
-
return experiment;
|
|
1021
|
-
}
|
|
1022
|
-
async function checkTargeting(supabase, userId, experiment, deviceMetadata) {
|
|
969
|
+
async function checkTargeting(store, userId, experiment, deviceMetadata) {
|
|
1023
970
|
if (!experiment.targeting_rules || experiment.targeting_rules.length === 0) return true;
|
|
1024
|
-
const factors = await queryFactors(
|
|
971
|
+
const factors = await queryFactors(store, userId, experiment.component_path);
|
|
1025
972
|
return evaluateTargeting(factors, experiment.targeting_rules, deviceMetadata);
|
|
1026
973
|
}
|
|
1027
|
-
async function fetchExperimentVariants(supabase, experimentId) {
|
|
1028
|
-
const { data, error } = await supabase.from("experiment_variants").select("variant_key, config, traffic_percentage").eq("experiment_id", experimentId).order("variant_key");
|
|
1029
|
-
if (error || !data) return [];
|
|
1030
|
-
return data;
|
|
1031
|
-
}
|
|
1032
974
|
function selectVariantByHash(userId, experimentId, variants) {
|
|
1033
975
|
const hashValue = simpleHash(`${userId}:${experimentId}`);
|
|
1034
976
|
const bucket = hashValue % 100;
|
|
@@ -1048,27 +990,22 @@ function simpleHash(input) {
|
|
|
1048
990
|
}
|
|
1049
991
|
return hash;
|
|
1050
992
|
}
|
|
1051
|
-
async function recordExposure(supabase, userId, assignment) {
|
|
1052
|
-
await supabase.from("experiment_exposures").insert({
|
|
1053
|
-
user_id: userId,
|
|
1054
|
-
experiment_id: assignment.experiment_id,
|
|
1055
|
-
variant_key: assignment.variant_key
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
993
|
|
|
1059
994
|
// src/experiment/lifecycle.ts
|
|
1060
|
-
async function createExperiment(
|
|
995
|
+
async function createExperiment(store, definition) {
|
|
1061
996
|
validateDefinition(definition);
|
|
1062
|
-
const experiment = await insertExperiment(
|
|
1063
|
-
|
|
997
|
+
const experiment = await store.insertExperiment({
|
|
998
|
+
name: definition.name,
|
|
999
|
+
description: definition.description ?? null,
|
|
1000
|
+
component_path: definition.component_path,
|
|
1001
|
+
targeting_rules: definition.targeting_rules ?? [],
|
|
1002
|
+
platforms: definition.platforms ?? []
|
|
1003
|
+
});
|
|
1004
|
+
await store.insertVariants(experiment.id, definition.variants);
|
|
1064
1005
|
return experiment;
|
|
1065
1006
|
}
|
|
1066
|
-
async function startExperiment(
|
|
1067
|
-
|
|
1068
|
-
if (error) throw new Error(`startExperiment failed: ${error.message}`);
|
|
1069
|
-
if (!data || data.length === 0) {
|
|
1070
|
-
throw new Error(`startExperiment: experiment ${experimentId} not found or not in draft status`);
|
|
1071
|
-
}
|
|
1007
|
+
async function startExperiment(store, experimentId) {
|
|
1008
|
+
await store.startExperiment(experimentId);
|
|
1072
1009
|
}
|
|
1073
1010
|
function validateDefinition(definition) {
|
|
1074
1011
|
if (definition.variants.length < 2) {
|
|
@@ -1083,41 +1020,20 @@ function validateDefinition(definition) {
|
|
|
1083
1020
|
throw new Error(`Traffic percentages must sum to 100, got ${totalTraffic}`);
|
|
1084
1021
|
}
|
|
1085
1022
|
}
|
|
1086
|
-
async function insertExperiment(client, definition) {
|
|
1087
|
-
const { data, error } = await client.from("experiments").insert({
|
|
1088
|
-
name: definition.name,
|
|
1089
|
-
description: definition.description ?? null,
|
|
1090
|
-
component_path: definition.component_path,
|
|
1091
|
-
targeting_rules: definition.targeting_rules ?? [],
|
|
1092
|
-
platforms: definition.platforms ?? []
|
|
1093
|
-
}).select("id, name, status, component_path").single();
|
|
1094
|
-
if (error) throw new Error(`insertExperiment failed: ${error.message}`);
|
|
1095
|
-
return data;
|
|
1096
|
-
}
|
|
1097
|
-
async function insertVariants(client, experimentId, variants) {
|
|
1098
|
-
const rows = variants.map((v) => ({
|
|
1099
|
-
experiment_id: experimentId,
|
|
1100
|
-
variant_key: v.variant_key,
|
|
1101
|
-
config: v.config,
|
|
1102
|
-
traffic_percentage: v.traffic_percentage
|
|
1103
|
-
}));
|
|
1104
|
-
const { error } = await client.from("experiment_variants").insert(rows);
|
|
1105
|
-
if (error) throw new Error(`insertVariants failed: ${error.message}`);
|
|
1106
|
-
}
|
|
1107
1023
|
|
|
1108
1024
|
// src/experiment/results.ts
|
|
1109
|
-
async function queryExperimentResults(
|
|
1110
|
-
const
|
|
1111
|
-
if (!
|
|
1112
|
-
const variantGroups = await
|
|
1025
|
+
async function queryExperimentResults(store, experimentId, factorNames) {
|
|
1026
|
+
const meta = await store.getExperimentMeta(experimentId);
|
|
1027
|
+
if (!meta) return [];
|
|
1028
|
+
const variantGroups = await store.getAssignmentsByVariant(experimentId);
|
|
1113
1029
|
const results = [];
|
|
1114
1030
|
for (const [variantKey, userIds] of variantGroups) {
|
|
1115
1031
|
const factorDeltas = await computeVariantDeltas(
|
|
1116
|
-
|
|
1032
|
+
store,
|
|
1117
1033
|
userIds,
|
|
1118
|
-
|
|
1034
|
+
meta.component_path,
|
|
1119
1035
|
factorNames,
|
|
1120
|
-
|
|
1036
|
+
meta.created_at
|
|
1121
1037
|
);
|
|
1122
1038
|
results.push({
|
|
1123
1039
|
variant_key: variantKey,
|
|
@@ -1127,35 +1043,17 @@ async function queryExperimentResults(client, experimentId, factorNames) {
|
|
|
1127
1043
|
}
|
|
1128
1044
|
return results;
|
|
1129
1045
|
}
|
|
1130
|
-
async function
|
|
1131
|
-
const { data, error } = await client.from("experiments").select("component_path, created_at").eq("id", experimentId).maybeSingle();
|
|
1132
|
-
if (error || !data) return null;
|
|
1133
|
-
return data;
|
|
1134
|
-
}
|
|
1135
|
-
async function fetchAssignmentsByVariant(client, experimentId) {
|
|
1136
|
-
const { data, error } = await client.from("experiment_assignments").select("variant_key, user_id").eq("experiment_id", experimentId);
|
|
1137
|
-
if (error || !data) return /* @__PURE__ */ new Map();
|
|
1138
|
-
const groups = /* @__PURE__ */ new Map();
|
|
1139
|
-
for (const row of data) {
|
|
1140
|
-
const existing = groups.get(row.variant_key) ?? [];
|
|
1141
|
-
existing.push(row.user_id);
|
|
1142
|
-
groups.set(row.variant_key, existing);
|
|
1143
|
-
}
|
|
1144
|
-
return groups;
|
|
1145
|
-
}
|
|
1146
|
-
async function computeVariantDeltas(client, userIds, componentPath, factorNames, experimentCreatedAt) {
|
|
1046
|
+
async function computeVariantDeltas(store, userIds, componentPath, factorNames, experimentCreatedAt) {
|
|
1147
1047
|
const beforeDate = new Date(experimentCreatedAt);
|
|
1148
1048
|
const afterDate = /* @__PURE__ */ new Date();
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
if (!data) return [];
|
|
1158
|
-
return data.map((row) => ({
|
|
1049
|
+
const rows = await store.bulkFactorDeltas(
|
|
1050
|
+
userIds,
|
|
1051
|
+
componentPath,
|
|
1052
|
+
factorNames,
|
|
1053
|
+
beforeDate.toISOString(),
|
|
1054
|
+
afterDate.toISOString()
|
|
1055
|
+
);
|
|
1056
|
+
return rows.map((row) => ({
|
|
1159
1057
|
factor_name: row.factor_name,
|
|
1160
1058
|
before: row.avg_before,
|
|
1161
1059
|
after: row.avg_after,
|
|
@@ -1164,30 +1062,15 @@ async function computeVariantDeltas(client, userIds, componentPath, factorNames,
|
|
|
1164
1062
|
}
|
|
1165
1063
|
|
|
1166
1064
|
// src/experiment/governance.ts
|
|
1167
|
-
async function evaluateExperimentThresholds(
|
|
1168
|
-
const
|
|
1169
|
-
if (!
|
|
1170
|
-
const results = await queryExperimentResults(
|
|
1171
|
-
const thresholds = await
|
|
1065
|
+
async function evaluateExperimentThresholds(store, experimentId, factorNames) {
|
|
1066
|
+
const meta = await store.getExperimentMeta(experimentId);
|
|
1067
|
+
if (!meta) return buildEmptyVerdict();
|
|
1068
|
+
const results = await queryExperimentResults(store, experimentId, factorNames);
|
|
1069
|
+
const thresholds = await store.queryThresholds(factorNames, meta.component_path);
|
|
1172
1070
|
return computeGovernanceVerdict(results, thresholds);
|
|
1173
1071
|
}
|
|
1174
|
-
async function concludeExperiment(
|
|
1175
|
-
|
|
1176
|
-
status: "concluded",
|
|
1177
|
-
concluded_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1178
|
-
winning_variant: winningVariant
|
|
1179
|
-
}).eq("id", experimentId).eq("status", "running");
|
|
1180
|
-
if (error) throw new Error(`concludeExperiment failed: ${error.message}`);
|
|
1181
|
-
}
|
|
1182
|
-
async function fetchExperimentComponentPath(client, experimentId) {
|
|
1183
|
-
const { data, error } = await client.from("experiments").select("component_path").eq("id", experimentId).maybeSingle();
|
|
1184
|
-
if (error || !data) return null;
|
|
1185
|
-
return data.component_path;
|
|
1186
|
-
}
|
|
1187
|
-
async function fetchThresholds(client, factorNames, componentPath) {
|
|
1188
|
-
const { data, error } = await client.from("thresholds").select("id, factor_name, component_path, operator, value, action").in("factor_name", factorNames).or(`component_path.eq.${componentPath},component_path.is.null`);
|
|
1189
|
-
if (error) throw new Error(`fetchThresholds failed: ${error.message}`);
|
|
1190
|
-
return data ?? [];
|
|
1072
|
+
async function concludeExperiment(store, experimentId, winningVariant) {
|
|
1073
|
+
await store.concludeExperiment(experimentId, winningVariant);
|
|
1191
1074
|
}
|
|
1192
1075
|
function computeGovernanceVerdict(results, thresholds) {
|
|
1193
1076
|
if (results.length < 2 || thresholds.length === 0) return buildEmptyVerdict();
|
|
@@ -1268,63 +1151,43 @@ function buildEmptyVerdict() {
|
|
|
1268
1151
|
}
|
|
1269
1152
|
|
|
1270
1153
|
// src/experiment/governance-check.ts
|
|
1271
|
-
async function logGovernanceVerdict(
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
verdict
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (error) throw new Error(`logGovernanceVerdict failed: ${error.message}`);
|
|
1154
|
+
async function logGovernanceVerdict(store, experimentId, verdict) {
|
|
1155
|
+
await store.insertGovernanceVerdict(
|
|
1156
|
+
experimentId,
|
|
1157
|
+
verdict.action,
|
|
1158
|
+
verdict.winning_variant,
|
|
1159
|
+
verdict.factor_verdicts
|
|
1160
|
+
);
|
|
1279
1161
|
}
|
|
1280
|
-
async function runGovernanceCheck(
|
|
1281
|
-
const verdict = await evaluateExperimentThresholds(
|
|
1282
|
-
await logGovernanceVerdict(
|
|
1283
|
-
await concludeIfWinner(
|
|
1162
|
+
async function runGovernanceCheck(store, experimentId, factorNames) {
|
|
1163
|
+
const verdict = await evaluateExperimentThresholds(store, experimentId, factorNames);
|
|
1164
|
+
await logGovernanceVerdict(store, experimentId, verdict);
|
|
1165
|
+
await concludeIfWinner(store, experimentId, verdict);
|
|
1284
1166
|
return verdict;
|
|
1285
1167
|
}
|
|
1286
|
-
async function concludeIfWinner(
|
|
1168
|
+
async function concludeIfWinner(store, experimentId, verdict) {
|
|
1287
1169
|
if (verdict.action === "conclude" && verdict.winning_variant) {
|
|
1288
|
-
await concludeExperiment(
|
|
1170
|
+
await concludeExperiment(store, experimentId, verdict.winning_variant);
|
|
1289
1171
|
}
|
|
1290
1172
|
}
|
|
1291
1173
|
|
|
1292
1174
|
// src/experiment/dashboard.ts
|
|
1293
|
-
async function queryExperimentSummaries(
|
|
1294
|
-
|
|
1295
|
-
query = applyFilters(query, filters);
|
|
1296
|
-
const { data, error } = await query;
|
|
1297
|
-
if (error) throw new Error(`queryExperimentSummaries failed: ${error.message}`);
|
|
1298
|
-
return data ?? [];
|
|
1175
|
+
async function queryExperimentSummaries(store, filters) {
|
|
1176
|
+
return store.queryExperimentSummaries(filters);
|
|
1299
1177
|
}
|
|
1300
|
-
function
|
|
1301
|
-
|
|
1302
|
-
if (filters.status) {
|
|
1303
|
-
query = query.eq("status", filters.status);
|
|
1304
|
-
}
|
|
1305
|
-
if (filters.component_path) {
|
|
1306
|
-
query = query.eq("component_path", filters.component_path);
|
|
1307
|
-
}
|
|
1308
|
-
if (filters.created_after) {
|
|
1309
|
-
query = query.gte("created_at", filters.created_after);
|
|
1310
|
-
}
|
|
1311
|
-
if (filters.created_before) {
|
|
1312
|
-
query = query.lte("created_at", filters.created_before);
|
|
1313
|
-
}
|
|
1314
|
-
return query;
|
|
1178
|
+
async function queryExperimentSummary(store, experimentId) {
|
|
1179
|
+
return store.queryExperimentSummary(experimentId);
|
|
1315
1180
|
}
|
|
1316
1181
|
|
|
1317
1182
|
// src/experiment/governance-log.ts
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
const { data, error } = await client.from("governance_log").select(GOVERNANCE_LOG_COLUMNS).eq("experiment_id", experimentId).order("evaluated_at", { ascending: false });
|
|
1321
|
-
if (error) throw new Error(`queryGovernanceLog failed: ${error.message}`);
|
|
1322
|
-
return data ?? [];
|
|
1183
|
+
async function queryGovernanceLog(store, experimentId) {
|
|
1184
|
+
return store.queryGovernanceLog(experimentId);
|
|
1323
1185
|
}
|
|
1324
|
-
async function queryRecentGovernanceLog(
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1186
|
+
async function queryRecentGovernanceLog(store, limit = 50) {
|
|
1187
|
+
return store.queryRecentGovernanceLog(limit);
|
|
1188
|
+
}
|
|
1189
|
+
async function queryGovernanceLogByVerdict(store, verdict) {
|
|
1190
|
+
return store.queryGovernanceLogByVerdict(verdict);
|
|
1328
1191
|
}
|
|
1329
1192
|
|
|
1330
1193
|
// src/sdui/spec-validator.ts
|
|
@@ -1551,8 +1414,8 @@ function dispatchAction(actionRef, registry, context) {
|
|
|
1551
1414
|
}
|
|
1552
1415
|
|
|
1553
1416
|
// src/sdui/spec-loader.ts
|
|
1554
|
-
async function loadSpec(
|
|
1555
|
-
const remote = await fetchRemoteSpec(
|
|
1417
|
+
async function loadSpec(store, platform, baseline, storage, verifier) {
|
|
1418
|
+
const remote = await fetchRemoteSpec(store, platform);
|
|
1556
1419
|
if (remote) {
|
|
1557
1420
|
const validated = await validateSignedSpec(remote, verifier);
|
|
1558
1421
|
if (validated) {
|
|
@@ -1582,28 +1445,13 @@ async function verifySpecHash(signed, verifier) {
|
|
|
1582
1445
|
const computedHash = await verifier.computeHash(signed.spec);
|
|
1583
1446
|
return computedHash === signed.spec_hash;
|
|
1584
1447
|
}
|
|
1585
|
-
async function fetchRemoteSpec(
|
|
1448
|
+
async function fetchRemoteSpec(store, platform) {
|
|
1586
1449
|
try {
|
|
1587
|
-
|
|
1588
|
-
if (error || !data) return null;
|
|
1589
|
-
return mapRowToSignedSpec(data);
|
|
1450
|
+
return await store.loadActiveSpec(platform);
|
|
1590
1451
|
} catch {
|
|
1591
1452
|
return null;
|
|
1592
1453
|
}
|
|
1593
1454
|
}
|
|
1594
|
-
function mapRowToSignedSpec(data) {
|
|
1595
|
-
const row = data;
|
|
1596
|
-
return {
|
|
1597
|
-
spec: {
|
|
1598
|
-
spec_version: row.ui_specs.spec_version,
|
|
1599
|
-
renderer_min: row.ui_specs.renderer_min,
|
|
1600
|
-
root: row.ui_specs.component_tree
|
|
1601
|
-
},
|
|
1602
|
-
signature: row.ui_specs.signature,
|
|
1603
|
-
signed_at: "",
|
|
1604
|
-
spec_hash: row.ui_specs.spec_hash
|
|
1605
|
-
};
|
|
1606
|
-
}
|
|
1607
1455
|
async function loadActiveSpec(storage) {
|
|
1608
1456
|
try {
|
|
1609
1457
|
return await storage.loadActive();
|
|
@@ -1754,7 +1602,9 @@ function bytesToHex(bytes) {
|
|
|
1754
1602
|
createDataSourceCache,
|
|
1755
1603
|
createEd25519Signer,
|
|
1756
1604
|
createEd25519Verifier,
|
|
1605
|
+
createEventWriter,
|
|
1757
1606
|
createExperiment,
|
|
1607
|
+
createSessionManager,
|
|
1758
1608
|
createSpecStorage,
|
|
1759
1609
|
createWebAdapter,
|
|
1760
1610
|
devSignatureVerifier,
|
|
@@ -1774,10 +1624,12 @@ function bytesToHex(bytes) {
|
|
|
1774
1624
|
queryComponentFactors,
|
|
1775
1625
|
queryExperimentResults,
|
|
1776
1626
|
queryExperimentSummaries,
|
|
1627
|
+
queryExperimentSummary,
|
|
1777
1628
|
queryFactorDelta,
|
|
1778
1629
|
queryFactorHistory,
|
|
1779
1630
|
queryFactors,
|
|
1780
1631
|
queryGovernanceLog,
|
|
1632
|
+
queryGovernanceLogByVerdict,
|
|
1781
1633
|
queryRecentGovernanceLog,
|
|
1782
1634
|
queryUserCluster,
|
|
1783
1635
|
resolveAllSources,
|