@intranefr/superbackend 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/analysis-only.skill +0 -0
  3. package/package.json +2 -1
  4. package/src/controllers/admin.controller.js +68 -1
  5. package/src/controllers/adminExperiments.controller.js +200 -0
  6. package/src/controllers/adminScripts.controller.js +105 -74
  7. package/src/controllers/experiments.controller.js +85 -0
  8. package/src/controllers/internalExperiments.controller.js +17 -0
  9. package/src/helpers/mongooseHelper.js +258 -0
  10. package/src/helpers/scriptBase.js +230 -0
  11. package/src/helpers/scriptRunner.js +335 -0
  12. package/src/middleware.js +65 -11
  13. package/src/models/CacheEntry.js +1 -1
  14. package/src/models/ConsoleLog.js +1 -1
  15. package/src/models/Experiment.js +75 -0
  16. package/src/models/ExperimentAssignment.js +23 -0
  17. package/src/models/ExperimentEvent.js +26 -0
  18. package/src/models/ExperimentMetricBucket.js +30 -0
  19. package/src/models/GlobalSetting.js +1 -2
  20. package/src/models/RateLimitCounter.js +1 -1
  21. package/src/models/ScriptDefinition.js +1 -0
  22. package/src/models/Webhook.js +2 -0
  23. package/src/routes/admin.routes.js +2 -0
  24. package/src/routes/adminConsoleManager.routes.js +1 -1
  25. package/src/routes/adminExperiments.routes.js +29 -0
  26. package/src/routes/blogInternal.routes.js +2 -2
  27. package/src/routes/experiments.routes.js +30 -0
  28. package/src/routes/internalExperiments.routes.js +15 -0
  29. package/src/services/blogCronsBootstrap.service.js +7 -6
  30. package/src/services/consoleManager.service.js +56 -18
  31. package/src/services/consoleOverride.service.js +1 -0
  32. package/src/services/experiments.service.js +273 -0
  33. package/src/services/experimentsAggregation.service.js +308 -0
  34. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  35. package/src/services/experimentsRetention.service.js +43 -0
  36. package/src/services/experimentsWs.service.js +134 -0
  37. package/src/services/globalSettings.service.js +15 -0
  38. package/src/services/jsonConfigs.service.js +2 -2
  39. package/src/services/scriptsRunner.service.js +214 -14
  40. package/src/utils/rbac/rightsRegistry.js +4 -0
  41. package/views/admin-dashboard.ejs +28 -8
  42. package/views/admin-experiments.ejs +91 -0
  43. package/views/admin-scripts.ejs +596 -2
  44. package/views/partials/dashboard/nav-items.ejs +1 -0
  45. package/views/partials/dashboard/palette.ejs +5 -3
  46. package/src/middleware/internalCronAuth.js +0 -29
@@ -0,0 +1,335 @@
1
+ const { ScriptBase } = require('./scriptBase');
2
+
3
+ /**
4
+ * Utility for running scripts with proper error handling and cleanup
5
+ * Provides CLI wrapper functionality and execution management
6
+ */
7
+ class ScriptRunner {
8
+ /**
9
+ * Run a script class or function
10
+ * @param {Function|ScriptBase} ScriptClass - Script class or function
11
+ * @param {Object} options - Execution options
12
+ * @returns {Promise<any>} Script result
13
+ */
14
+ static async run(ScriptClass, options = {}) {
15
+ let script;
16
+
17
+ try {
18
+ // Handle different script types
19
+ script = ScriptRunner._createScriptInstance(ScriptClass, options);
20
+
21
+ // Validate script before running
22
+ const validation = script.validate();
23
+ if (!validation.valid) {
24
+ throw new Error(`Script validation failed: ${validation.errors.join(', ')}`);
25
+ }
26
+
27
+ // Log warnings if any
28
+ if (validation.warnings.length > 0) {
29
+ validation.warnings.forEach(warning => {
30
+ console.warn(`[ScriptRunner] Warning: ${warning}`);
31
+ });
32
+ }
33
+
34
+ return await script.run();
35
+ } catch (error) {
36
+ console.error('[ScriptRunner] Execution failed:', error.message);
37
+
38
+ if (script) {
39
+ await script.cleanup(script.context);
40
+ }
41
+
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Create script instance from various input types
48
+ * @private
49
+ * @param {Function|ScriptBase} ScriptClass - Script class or function
50
+ * @param {Object} options - Options for script creation
51
+ * @returns {ScriptBase} Script instance
52
+ */
53
+ static _createScriptInstance(ScriptClass, options = {}) {
54
+ // Handle plain async functions
55
+ if (typeof ScriptClass === 'function' && !ScriptBase.prototype.isPrototypeOf(ScriptClass.prototype)) {
56
+ // Wrap the function in a ScriptBase class
57
+ const WrappedScript = class extends ScriptBase {
58
+ async execute(context) {
59
+ return await ScriptClass(context, options);
60
+ }
61
+ };
62
+
63
+ return new WrappedScript(options);
64
+ }
65
+
66
+ // Handle ScriptBase classes
67
+ if (typeof ScriptClass === 'function' && ScriptBase.prototype.isPrototypeOf(ScriptClass.prototype)) {
68
+ return new ScriptClass(options);
69
+ }
70
+
71
+ // Handle ScriptBase instances
72
+ if (ScriptBase.prototype.isPrototypeOf(ScriptClass)) {
73
+ return ScriptClass;
74
+ }
75
+
76
+ throw new Error('Invalid script type: must be a function, ScriptBase class, or ScriptBase instance');
77
+ }
78
+
79
+ /**
80
+ * Create a CLI wrapper for scripts
81
+ * @param {Function|ScriptBase} ScriptClass - Script class or function
82
+ * @param {Object} defaultOptions - Default options
83
+ * @returns {Function} CLI-ready function
84
+ */
85
+ static createCli(ScriptClass, defaultOptions = {}) {
86
+ return async (options = {}) => {
87
+ const mergedOptions = { ...defaultOptions, ...options };
88
+
89
+ // Handle CLI execution
90
+ if (require.main === module) {
91
+ try {
92
+ // Parse command line arguments if provided
93
+ const cliOptions = ScriptRunner._parseCliArgs();
94
+ const finalOptions = { ...mergedOptions, ...cliOptions };
95
+
96
+ await ScriptRunner.run(ScriptClass, finalOptions);
97
+ console.log('✅ Script completed successfully');
98
+ process.exit(0);
99
+ } catch (error) {
100
+ console.error('❌ Script failed:', error.message);
101
+
102
+ // Show stack trace in debug mode
103
+ if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
104
+ console.error(error.stack);
105
+ }
106
+
107
+ process.exit(1);
108
+ }
109
+ } else {
110
+ // Return result when required as module
111
+ return await ScriptRunner.run(ScriptClass, mergedOptions);
112
+ }
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Parse command line arguments
118
+ * @private
119
+ * @returns {Object} Parsed arguments
120
+ */
121
+ static _parseCliArgs() {
122
+ const args = process.argv.slice(2);
123
+ const options = {};
124
+
125
+ for (let i = 0; i < args.length; i++) {
126
+ const arg = args[i];
127
+
128
+ // Handle --key=value or --key value format
129
+ if (arg.startsWith('--')) {
130
+ const equalsIndex = arg.indexOf('=');
131
+
132
+ if (equalsIndex > 0) {
133
+ // --key=value format
134
+ const key = arg.substring(2, equalsIndex);
135
+ const value = arg.substring(equalsIndex + 1);
136
+ options[key] = ScriptRunner._parseValue(value);
137
+ } else {
138
+ // --key value format
139
+ const key = arg.substring(2);
140
+
141
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
142
+ const value = args[i + 1];
143
+ options[key] = ScriptRunner._parseValue(value);
144
+ i++; // Skip next argument as it's a value
145
+ } else {
146
+ // Boolean flag
147
+ options[key] = true;
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return options;
154
+ }
155
+
156
+ /**
157
+ * Parse string value to appropriate type
158
+ * @private
159
+ * @param {string} value - String value to parse
160
+ * @returns {any} Parsed value
161
+ */
162
+ static _parseValue(value) {
163
+ // Try parsing as JSON
164
+ try {
165
+ return JSON.parse(value);
166
+ } catch {
167
+ // Fallback to string
168
+ return value;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Create a batch script runner for multiple scripts
174
+ * @param {Array} scripts - Array of script configurations
175
+ * @param {Object} options - Batch options
176
+ * @returns {Function} Batch runner function
177
+ */
178
+ static createBatch(scripts, options = {}) {
179
+ const {
180
+ stopOnError = true,
181
+ parallel = false,
182
+ maxConcurrency = 3
183
+ } = options;
184
+
185
+ return async (runOptions = {}) => {
186
+ const results = [];
187
+ const errors = [];
188
+
189
+ console.log(`[BatchRunner] Running ${scripts.length} scripts (${parallel ? 'parallel' : 'sequential'})`);
190
+
191
+ if (parallel) {
192
+ // Run scripts in parallel with concurrency limit
193
+ const chunks = ScriptRunner._chunkArray(scripts, maxConcurrency);
194
+
195
+ for (const chunk of chunks) {
196
+ const chunkPromises = chunk.map(async (scriptConfig, index) => {
197
+ try {
198
+ const { script, options: scriptOptions } = scriptConfig;
199
+ const result = await ScriptRunner.run(script, { ...runOptions, ...scriptOptions });
200
+ return { index, result, error: null };
201
+ } catch (error) {
202
+ return { index, result: null, error };
203
+ }
204
+ });
205
+
206
+ const chunkResults = await Promise.all(chunkPromises);
207
+
208
+ for (const { index, result, error } of chunkResults) {
209
+ if (error) {
210
+ errors.push({ index, error });
211
+ if (stopOnError) {
212
+ throw error;
213
+ }
214
+ } else {
215
+ results[index] = result;
216
+ }
217
+ }
218
+ }
219
+ } else {
220
+ // Run scripts sequentially
221
+ for (let i = 0; i < scripts.length; i++) {
222
+ const { script, options: scriptOptions } = scripts[i];
223
+
224
+ try {
225
+ console.log(`[BatchRunner] Running script ${i + 1}/${scripts.length}`);
226
+ const result = await ScriptRunner.run(script, { ...runOptions, ...scriptOptions });
227
+ results.push(result);
228
+ } catch (error) {
229
+ errors.push({ index: i, error });
230
+ if (stopOnError) {
231
+ throw error;
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ return { results, errors };
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Split array into chunks
243
+ * @private
244
+ * @param {Array} array - Array to split
245
+ * @param {number} size - Chunk size
246
+ * @returns {Array} Array of chunks
247
+ */
248
+ static _chunkArray(array, size) {
249
+ const chunks = [];
250
+ for (let i = 0; i < array.length; i += size) {
251
+ chunks.push(array.slice(i, i + size));
252
+ }
253
+ return chunks;
254
+ }
255
+
256
+ /**
257
+ * Create a scheduled script runner
258
+ * @param {Function|ScriptBase} ScriptClass - Script to run
259
+ * @param {Object} schedule - Schedule configuration
260
+ * @returns {Object} Scheduled runner
261
+ */
262
+ static createScheduled(ScriptClass, schedule = {}) {
263
+ const {
264
+ interval,
265
+ cron,
266
+ maxRuns = null,
267
+ runOnStart = false
268
+ } = schedule;
269
+
270
+ let runCount = 0;
271
+ let timer = null;
272
+
273
+ const scheduledRunner = {
274
+ isRunning: false,
275
+ start() {
276
+ if (this.isRunning) {
277
+ throw new Error('Scheduled runner is already running');
278
+ }
279
+
280
+ this.isRunning = true;
281
+
282
+ if (runOnStart) {
283
+ this.runScript();
284
+ }
285
+
286
+ if (interval) {
287
+ timer = setInterval(() => this.runScript(), interval);
288
+ } else if (cron) {
289
+ // Note: Would need a cron library implementation
290
+ throw new Error('Cron scheduling not implemented yet');
291
+ } else {
292
+ throw new Error('Must specify either interval or cron schedule');
293
+ }
294
+ },
295
+
296
+ stop() {
297
+ if (timer) {
298
+ clearInterval(timer);
299
+ timer = null;
300
+ }
301
+ this.isRunning = false;
302
+ },
303
+
304
+ async runScript() {
305
+ if (maxRuns && runCount >= maxRuns) {
306
+ console.log('[ScheduledRunner] Maximum runs reached, stopping');
307
+ this.stop();
308
+ return;
309
+ }
310
+
311
+ try {
312
+ runCount++;
313
+ console.log(`[ScheduledRunner] Run #${runCount}`);
314
+ await ScriptRunner.run(ScriptClass);
315
+ } catch (error) {
316
+ console.error('[ScheduledRunner] Scheduled run failed:', error);
317
+ }
318
+ },
319
+
320
+ getStatus() {
321
+ return {
322
+ isRunning: this.isRunning,
323
+ runCount,
324
+ maxRuns,
325
+ interval,
326
+ cron
327
+ };
328
+ }
329
+ };
330
+
331
+ return scheduledRunner;
332
+ }
333
+ }
334
+
335
+ module.exports = { ScriptRunner };
package/src/middleware.js CHANGED
@@ -1,10 +1,20 @@
1
1
  const consoleOverride = require("./services/consoleOverride.service");
2
- const consoleManager = require("./services/consoleManager.service");
2
+ const { consoleManager } = require("./services/consoleManager.service");
3
3
 
4
4
  // Initialize console override service early to capture all logs
5
5
  // Avoid keeping timers/streams alive during Jest runs.
6
6
  if (process.env.NODE_ENV !== "test" && !process.env.JEST_WORKER_ID) {
7
7
  consoleOverride.init()
8
+
9
+ // Initialize console manager after a short delay to ensure consoleOverride is fully set up
10
+ setTimeout(() => {
11
+ // Set module prefix for this middleware
12
+ consoleManager.setModulePrefix('middleware');
13
+
14
+ // Initialize console manager early to enable prefixing for all subsequent logs
15
+ consoleManager.init();
16
+ console.log("[Console Manager] Initialized - prefixing enabled");
17
+ }, 20);
8
18
  }
9
19
 
10
20
  const express = require("express");
@@ -119,6 +129,11 @@ function createMiddleware(options = {}) {
119
129
  attachTerminalWebsocketServer,
120
130
  } = require("./services/terminalsWs.service");
121
131
  attachTerminalWebsocketServer(server, { basePathPrefix: adminPath });
132
+
133
+ const {
134
+ attachExperimentsWebsocketServer,
135
+ } = require("./services/experimentsWs.service");
136
+ attachExperimentsWebsocketServer(server);
122
137
  };
123
138
 
124
139
  if (!errorCaptureInitialized) {
@@ -156,17 +171,10 @@ function createMiddleware(options = {}) {
156
171
  await healthChecksScheduler.start();
157
172
  await healthChecksBootstrap.bootstrap();
158
173
  await blogCronsBootstrap.bootstrap();
174
+ await require("./services/experimentsCronsBootstrap.service").bootstrap();
159
175
 
160
- // Initialize console manager AFTER database is connected
161
- if (process.env.NODE_ENV !== "test" && !process.env.JEST_WORKER_ID) {
162
- const consoleManagerEnabled = await isConsoleManagerEnabled();
163
- if (consoleManagerEnabled) {
164
- consoleManager.init();
165
- console.log("[Console Manager] Initialized");
166
- } else {
167
- console.log("[Console Manager] Disabled - console methods not overridden");
168
- }
169
- }
176
+ // Console manager is already initialized early in the middleware
177
+ console.log("[Console Manager] MongoDB connection established");
170
178
 
171
179
  return true;
172
180
  })
@@ -254,12 +262,20 @@ function createMiddleware(options = {}) {
254
262
  blogCronsBootstrap.bootstrap().catch((err) => {
255
263
  console.error("Failed to bootstrap blog crons:", err);
256
264
  });
265
+
266
+ require("./services/experimentsCronsBootstrap.service")
267
+ .bootstrap()
268
+ .catch((err) => {
269
+ console.error("Failed to bootstrap experiments crons:", err);
270
+ });
257
271
 
258
272
  // Initialize console manager AFTER database is already connected
259
273
  if (process.env.NODE_ENV !== "test" && !process.env.JEST_WORKER_ID) {
260
274
  isConsoleManagerEnabled().then(consoleManagerEnabled => {
261
275
  if (consoleManagerEnabled) {
262
276
  consoleManager.init();
277
+ // Set module prefix after initialization
278
+ consoleManager.setModulePrefix('middleware');
263
279
  console.log("[Console Manager] Initialized");
264
280
  } else {
265
281
  console.log("[Console Manager] Disabled - console methods not overridden");
@@ -268,6 +284,8 @@ function createMiddleware(options = {}) {
268
284
  console.error("[Console Manager] Error checking enabled status:", error);
269
285
  console.log("[Console Manager] Fallback to enabled due to error");
270
286
  consoleManager.init();
287
+ // Set module prefix after initialization
288
+ consoleManager.setModulePrefix('middleware');
271
289
  console.log("[Console Manager] Initialized (fallback)");
272
290
  });
273
291
  }
@@ -467,6 +485,37 @@ function createMiddleware(options = {}) {
467
485
  });
468
486
  });
469
487
 
488
+ router.get(`${adminPath}/experiments`, basicAuth, (req, res) => {
489
+ const templatePath = path.join(
490
+ __dirname,
491
+ "..",
492
+ "views",
493
+ "admin-experiments.ejs",
494
+ );
495
+ fs.readFile(templatePath, "utf8", (err, template) => {
496
+ if (err) {
497
+ console.error("Error reading template:", err);
498
+ return res.status(500).send("Error loading page");
499
+ }
500
+ try {
501
+ const html = ejs.render(
502
+ template,
503
+ {
504
+ baseUrl: req.baseUrl,
505
+ adminPath,
506
+ },
507
+ {
508
+ filename: templatePath,
509
+ },
510
+ );
511
+ res.send(html);
512
+ } catch (renderErr) {
513
+ console.error("Error rendering template:", renderErr);
514
+ res.status(500).send("Error rendering page");
515
+ }
516
+ });
517
+ });
518
+
470
519
  router.get(`${adminPath}/rbac`, basicAuth, (req, res) => {
471
520
  const templatePath = path.join(__dirname, "..", "views", "admin-rbac.ejs");
472
521
  fs.readFile(templatePath, "utf8", (err, template) => {
@@ -677,6 +726,7 @@ function createMiddleware(options = {}) {
677
726
  require("./routes/adminDbBrowser.routes"),
678
727
  );
679
728
  router.use("/api/admin/terminals", require("./routes/adminTerminals.routes"));
729
+ router.use("/api/admin/experiments", require("./routes/adminExperiments.routes"));
680
730
  router.use("/api/admin/assets", require("./routes/adminAssets.routes"));
681
731
  router.use(
682
732
  "/api/admin/upload-namespaces",
@@ -724,6 +774,7 @@ function createMiddleware(options = {}) {
724
774
  router.use("/api/ui-components", require("./routes/uiComponentsPublic.routes"));
725
775
  router.use("/api/rbac", require("./routes/rbac.routes"));
726
776
  router.use("/api/file-manager", require("./routes/fileManager.routes"));
777
+ router.use("/api/experiments", require("./routes/experiments.routes"));
727
778
 
728
779
  // Public blog APIs (headless)
729
780
  router.use("/api", require("./routes/blogPublic.routes"));
@@ -731,6 +782,9 @@ function createMiddleware(options = {}) {
731
782
  // Internal blog endpoints (used by HTTP CronJobs)
732
783
  router.use("/api/internal", require("./routes/blogInternal.routes"));
733
784
 
785
+ // Internal experiments endpoints (used by HTTP CronJobs)
786
+ router.use("/api/internal", require("./routes/internalExperiments.routes"));
787
+
734
788
  // Public health checks status (gated by global setting)
735
789
  router.use(
736
790
  "/api/health-checks",
@@ -10,7 +10,7 @@ const cacheEntrySchema = new mongoose.Schema(
10
10
 
11
11
  sizeBytes: { type: Number, default: 0 },
12
12
 
13
- expiresAt: { type: Date, default: null, index: true },
13
+ expiresAt: { type: Date, default: null },
14
14
 
15
15
  hits: { type: Number, default: 0 },
16
16
  lastAccessAt: { type: Date, default: null },
@@ -12,7 +12,7 @@ const consoleLogSchema = new mongoose.Schema(
12
12
  requestId: { type: String, default: '', index: true },
13
13
 
14
14
  createdAt: { type: Date, default: Date.now, index: true },
15
- expiresAt: { type: Date, default: null, index: true },
15
+ expiresAt: { type: Date, default: null },
16
16
  },
17
17
  { timestamps: false, collection: 'console_logs' },
18
18
  );
@@ -0,0 +1,75 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const experimentVariantSchema = new mongoose.Schema(
4
+ {
5
+ key: { type: String, required: true },
6
+ weight: { type: Number, default: 0 },
7
+ configSlug: { type: String, default: '' },
8
+ },
9
+ { _id: false },
10
+ );
11
+
12
+ const metricDefinitionSchema = new mongoose.Schema(
13
+ {
14
+ key: { type: String, required: true },
15
+ kind: { type: String, enum: ['count', 'sum', 'avg', 'rate'], default: 'count' },
16
+ numeratorEventKey: { type: String, default: '' },
17
+ denominatorEventKey: { type: String, default: '' },
18
+ objective: { type: String, enum: ['maximize', 'minimize'], default: 'maximize' },
19
+ },
20
+ { _id: false },
21
+ );
22
+
23
+ const winnerPolicySchema = new mongoose.Schema(
24
+ {
25
+ mode: { type: String, enum: ['manual', 'automatic'], default: 'manual' },
26
+ pickAfterMs: { type: Number, default: 0 },
27
+ minAssignments: { type: Number, default: 0 },
28
+ minExposures: { type: Number, default: 0 },
29
+ minConversions: { type: Number, default: 0 },
30
+ statMethod: { type: String, enum: ['simple_rate', 'bayesian_beta'], default: 'simple_rate' },
31
+ overrideWinnerVariantKey: { type: String, default: '' },
32
+ },
33
+ { _id: false },
34
+ );
35
+
36
+ const experimentSchema = new mongoose.Schema(
37
+ {
38
+ organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
39
+
40
+ code: { type: String, required: true },
41
+ name: { type: String, default: '' },
42
+ description: { type: String, default: '' },
43
+
44
+ status: { type: String, enum: ['draft', 'running', 'paused', 'completed'], default: 'draft', index: true },
45
+
46
+ startedAt: { type: Date, default: null },
47
+ endsAt: { type: Date, default: null },
48
+
49
+ assignment: {
50
+ unit: { type: String, enum: ['subjectId'], default: 'subjectId' },
51
+ sticky: { type: Boolean, default: true },
52
+ salt: { type: String, default: '' },
53
+ },
54
+
55
+ variants: { type: [experimentVariantSchema], default: [] },
56
+
57
+ primaryMetric: { type: metricDefinitionSchema, required: true },
58
+ secondaryMetrics: { type: [metricDefinitionSchema], default: [] },
59
+
60
+ winnerPolicy: { type: winnerPolicySchema, default: () => ({}) },
61
+
62
+ winnerVariantKey: { type: String, default: '' },
63
+ winnerDecidedAt: { type: Date, default: null },
64
+ winnerReason: { type: String, default: '' },
65
+
66
+ createdByUserId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
67
+ updatedByUserId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
68
+ },
69
+ { timestamps: true, collection: 'experiments' },
70
+ );
71
+
72
+ experimentSchema.index({ organizationId: 1, code: 1 }, { unique: true });
73
+ experimentSchema.index({ status: 1, startedAt: 1 });
74
+
75
+ module.exports = mongoose.models.Experiment || mongoose.model('Experiment', experimentSchema);
@@ -0,0 +1,23 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const experimentAssignmentSchema = new mongoose.Schema(
4
+ {
5
+ experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
6
+ organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
7
+
8
+ subjectKey: { type: String, required: true },
9
+
10
+ variantKey: { type: String, required: true },
11
+ assignedAt: { type: Date, default: () => new Date() },
12
+
13
+ context: { type: mongoose.Schema.Types.Mixed, default: {} },
14
+ },
15
+ { timestamps: true, collection: 'experiment_assignments' },
16
+ );
17
+
18
+ experimentAssignmentSchema.index({ experimentId: 1, subjectKey: 1 }, { unique: true });
19
+ experimentAssignmentSchema.index({ organizationId: 1, subjectKey: 1 });
20
+
21
+ module.exports =
22
+ mongoose.models.ExperimentAssignment ||
23
+ mongoose.model('ExperimentAssignment', experimentAssignmentSchema);
@@ -0,0 +1,26 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const experimentEventSchema = new mongoose.Schema(
4
+ {
5
+ experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
6
+ organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
7
+
8
+ subjectKey: { type: String, required: true },
9
+
10
+ variantKey: { type: String, required: true, index: true },
11
+
12
+ eventKey: { type: String, required: true, index: true },
13
+ value: { type: Number, default: 1 },
14
+
15
+ ts: { type: Date, required: true, index: true },
16
+
17
+ meta: { type: mongoose.Schema.Types.Mixed, default: {} },
18
+ },
19
+ { timestamps: true, collection: 'experiment_events' },
20
+ );
21
+
22
+ experimentEventSchema.index({ experimentId: 1, ts: 1 });
23
+ experimentEventSchema.index({ organizationId: 1, ts: 1 });
24
+ experimentEventSchema.index({ experimentId: 1, eventKey: 1, ts: 1 });
25
+
26
+ module.exports = mongoose.models.ExperimentEvent || mongoose.model('ExperimentEvent', experimentEventSchema);
@@ -0,0 +1,30 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const experimentMetricBucketSchema = new mongoose.Schema(
4
+ {
5
+ experimentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Experiment', required: true, index: true },
6
+ organizationId: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', default: null, index: true },
7
+
8
+ variantKey: { type: String, required: true, index: true },
9
+ metricKey: { type: String, required: true, index: true },
10
+
11
+ bucketStart: { type: Date, required: true, index: true },
12
+ bucketMs: { type: Number, required: true },
13
+
14
+ count: { type: Number, default: 0 },
15
+ sum: { type: Number, default: 0 },
16
+ sumSq: { type: Number, default: 0 },
17
+ min: { type: Number, default: null },
18
+ max: { type: Number, default: null },
19
+ },
20
+ { timestamps: true, collection: 'experiment_metric_buckets' },
21
+ );
22
+
23
+ experimentMetricBucketSchema.index(
24
+ { experimentId: 1, variantKey: 1, metricKey: 1, bucketStart: 1, bucketMs: 1 },
25
+ { unique: true },
26
+ );
27
+
28
+ module.exports =
29
+ mongoose.models.ExperimentMetricBucket ||
30
+ mongoose.model('ExperimentMetricBucket', experimentMetricBucketSchema);
@@ -4,8 +4,7 @@ const globalSettingSchema = new mongoose.Schema({
4
4
  key: {
5
5
  type: String,
6
6
  required: true,
7
- unique: true,
8
- index: true
7
+ unique: true
9
8
  },
10
9
  value: {
11
10
  type: String,
@@ -8,7 +8,7 @@ const rateLimitCounterSchema = new mongoose.Schema(
8
8
 
9
9
  count: { type: Number, default: 0 },
10
10
 
11
- expiresAt: { type: Date, default: null, index: true },
11
+ expiresAt: { type: Date, default: null },
12
12
  },
13
13
  { timestamps: true, collection: 'rate_limit_counters' },
14
14
  );
@@ -16,6 +16,7 @@ const scriptDefinitionSchema = new mongoose.Schema(
16
16
  type: { type: String, enum: ['bash', 'node', 'browser'], required: true },
17
17
  runner: { type: String, enum: ['host', 'vm2', 'browser'], required: true },
18
18
  script: { type: String, required: true },
19
+ scriptFormat: { type: String, enum: ['string', 'base64'], default: 'string' },
19
20
  defaultWorkingDirectory: { type: String, default: '' },
20
21
  env: { type: [envVarSchema], default: [] },
21
22
  timeoutMs: { type: Number, default: 5 * 60 * 1000 },