make-mp-data 1.5.1 → 1.5.3

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 (40) hide show
  1. package/.gcloudignore +17 -0
  2. package/.vscode/launch.json +37 -14
  3. package/.vscode/settings.json +2 -0
  4. package/.vscode/tasks.json +12 -0
  5. package/components/ai.js +93 -0
  6. package/components/chart.js +14 -0
  7. package/components/cli.js +8 -2
  8. package/components/project.js +11 -0
  9. package/components/prompt.txt +98 -0
  10. package/components/utils.js +126 -5
  11. package/{schemas → dungeons}/adspend.js +1 -1
  12. package/{schemas → dungeons}/anon.js +1 -1
  13. package/{schemas → dungeons}/big.js +1 -1
  14. package/{schemas → dungeons}/business.js +1 -1
  15. package/{schemas → dungeons}/complex.js +9 -9
  16. package/dungeons/foobar.js +241 -0
  17. package/{schemas → dungeons}/funnels.js +2 -3
  18. package/dungeons/gaming.js +314 -0
  19. package/{schemas → dungeons}/mirror.js +1 -1
  20. package/{schemas → dungeons}/sanity.js +1 -1
  21. package/dungeons/scd.js +205 -0
  22. package/dungeons/session-replay.js +175 -0
  23. package/{schemas → dungeons}/simple.js +1 -1
  24. package/dungeons/userAgent.js +190 -0
  25. package/env.yaml +1 -0
  26. package/index.js +453 -154
  27. package/package.json +9 -5
  28. package/scripts/deploy.sh +11 -0
  29. package/scripts/new-dungeon.sh +10 -4
  30. package/tests/benchmark/concurrency.mjs +2 -2
  31. package/tests/cli.test.js +121 -0
  32. package/tests/e2e.test.js +134 -186
  33. package/tests/int.test.js +3 -2
  34. package/tests/jest.config.js +8 -0
  35. package/tests/unit.test.js +1 -1
  36. package/tsconfig.json +1 -1
  37. package/types.d.ts +40 -9
  38. package/schemas/foobar.js +0 -125
  39. package/schemas/session-replay.js +0 -136
  40. /package/dungeons/{.gitkeep → customers/.gitkeep} +0 -0
package/index.js CHANGED
@@ -10,6 +10,8 @@ ak@mixpanel.com
10
10
  //todo: regular interval events (like 'card charged')
11
11
  //todo: SCDs send to mixpanel
12
12
  //todo: decent 'new dungeon' workflow
13
+ //todo: validation that funnel events exist
14
+ //todo: ability to catch events not in funnels and make them random...
13
15
 
14
16
 
15
17
  //TIME
@@ -20,16 +22,17 @@ const FIXED_NOW = dayjs('2024-02-02').unix();
20
22
  global.FIXED_NOW = FIXED_NOW;
21
23
  // ^ this creates a FIXED POINT in time; we will shift it later
22
24
  let FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(90, 'd').unix();
25
+ global.FIXED_BEGIN = FIXED_BEGIN;
23
26
  const actualNow = dayjs();
24
27
  const timeShift = actualNow.diff(dayjs.unix(FIXED_NOW), "seconds");
25
28
  const daysShift = actualNow.diff(dayjs.unix(FIXED_NOW), "days");
26
29
 
27
30
  // UTILS
28
- const { existsSync } = require("fs");
31
+ const { existsSync, writeFileSync } = require("fs");
29
32
  const pLimit = require('p-limit');
30
33
  const os = require("os");
31
34
  const path = require("path");
32
- const { comma, bytesHuman, makeName, md5, clone, tracker, uid, timer, ls, rm } = require("ak-tools");
35
+ const { comma, bytesHuman, makeName, md5, clone, tracker, uid, timer, ls, rm, touch, load, sLog } = require("ak-tools");
33
36
  const jobTimer = timer('job');
34
37
  const { generateLineChart } = require('./components/chart.js');
35
38
  const { version } = require('./package.json');
@@ -37,6 +40,11 @@ const mp = require("mixpanel-import");
37
40
  const u = require("./components/utils.js");
38
41
  const getCliParams = require("./components/cli.js");
39
42
  const metrics = tracker("make-mp-data", "db99eb8f67ae50949a13c27cacf57d41", os.userInfo().username);
43
+ const t = require('ak-tools');
44
+
45
+
46
+ //CLOUD
47
+ const functions = require('@google-cloud/functions-framework');
40
48
 
41
49
  // DEFAULTS
42
50
  const { campaigns, devices, locations } = require('./components/defaults.js');
@@ -48,13 +56,17 @@ let STORAGE;
48
56
  let CONFIG;
49
57
  require('dotenv').config();
50
58
 
59
+ const { NODE_ENV = "unknown" } = process.env;
60
+
61
+
62
+
51
63
 
52
64
  // RUN STATE
53
65
  let VERBOSE = false;
54
66
  let isCLI = false;
55
67
  // if we are running in batch mode, we MUST write to disk before we can send to mixpanel
56
68
  let isBATCH_MODE = false;
57
- let BATCH_SIZE = 500_000;
69
+ let BATCH_SIZE = 1_000_000;
58
70
 
59
71
  //todo: these should be moved into the hookedArrays
60
72
  let operations = 0;
@@ -95,7 +107,7 @@ async function main(config) {
95
107
 
96
108
  //TRACKING
97
109
  const runId = uid(42);
98
- const { events, superProps, userProps, scdProps, groupKeys, groupProps, lookupTables, soup, hook, mirrorProps, ...trackingParams } = config;
110
+ const { events, superProps, userProps, scdProps, groupKeys, groupProps, lookupTables, soup, hook, mirrorProps, token: source_proj_token, ...trackingParams } = config;
99
111
  let { funnels } = config;
100
112
  trackingParams.runId = runId;
101
113
  trackingParams.version = version;
@@ -106,11 +118,14 @@ async function main(config) {
106
118
  const eventData = await makeHookArray([], { hook, type: "event", config, format, filepath: `${simulationName}-EVENTS` });
107
119
  const userProfilesData = await makeHookArray([], { hook, type: "user", config, format, filepath: `${simulationName}-USERS` });
108
120
  const adSpendData = await makeHookArray([], { hook, type: "ad-spend", config, format, filepath: `${simulationName}-AD-SPEND` });
121
+ const groupEventData = await makeHookArray([], { hook, type: "group-event", config, format, filepath: `${simulationName}-GROUP-EVENTS` });
109
122
 
110
123
  // SCDs, Groups, + Lookups may have multiple tables
111
124
  const scdTableKeys = Object.keys(scdProps);
112
125
  const scdTableData = await Promise.all(scdTableKeys.map(async (key) =>
113
- await makeHookArray([], { hook, type: "scd", config, format, scdKey: key, filepath: `${simulationName}-SCD-${key}` })
126
+ //todo don't assume everything is a string... lol
127
+ // @ts-ignore
128
+ await makeHookArray([], { hook, type: "scd", config, format, scdKey: key, entityType: config.scdProps[key].type, dataType: "string", filepath: `${simulationName}-${scdProps[key]?.type || "user"}-SCD-${key}` })
114
129
  ));
115
130
  const groupTableKeys = Object.keys(groupKeys);
116
131
  const groupProfilesData = await Promise.all(groupTableKeys.map(async (key, index) => {
@@ -126,7 +141,17 @@ async function main(config) {
126
141
 
127
142
  const mirrorEventData = await makeHookArray([], { hook, type: "mirror", config, format, filepath: `${simulationName}-MIRROR` });
128
143
 
129
- STORAGE = { eventData, userProfilesData, scdTableData, groupProfilesData, lookupTableData, mirrorEventData, adSpendData };
144
+ STORAGE = {
145
+ eventData,
146
+ userProfilesData,
147
+ scdTableData,
148
+ groupProfilesData,
149
+ lookupTableData,
150
+ mirrorEventData,
151
+ adSpendData,
152
+ groupEventData
153
+
154
+ };
130
155
 
131
156
 
132
157
  track('start simulation', trackingParams);
@@ -159,22 +184,72 @@ async function main(config) {
159
184
  log("\n");
160
185
 
161
186
  //GROUP PROFILES
187
+ const groupSCDs = t.objFilter(scdProps, (scd) => scd.type !== 'user');
162
188
  for (const [index, groupPair] of groupKeys.entries()) {
163
189
  const groupKey = groupPair[0];
164
190
  const groupCardinality = groupPair[1];
165
191
  for (let i = 1; i < groupCardinality + 1; i++) {
166
192
  if (VERBOSE) u.progress([["groups", i]]);
167
- const props = await makeProfile(groupProps[groupKey]);
193
+
194
+ const props = await makeProfile(groupProps[groupKey], { created: () => { return dayjs().subtract(u.integer(0, CONFIG.numDays || 30), 'd').toISOString(); } });
168
195
  const group = {
169
196
  [groupKey]: i,
170
197
  ...props,
171
198
  };
172
199
  group["distinct_id"] = i.toString();
173
200
  await groupProfilesData[index].hookPush(group);
201
+
202
+ //SCDs
203
+ const thisGroupSCD = t.objFilter(groupSCDs, (scd) => scd.type === groupKey);
204
+ const groupSCDKeys = Object.keys(thisGroupSCD);
205
+ const groupSCD = {};
206
+ for (const [index, key] of groupSCDKeys.entries()) {
207
+ const { max = 100 } = groupSCDs[key];
208
+ const mutations = chance.integer({ min: 2, max });
209
+ const changes = await makeSCD(scdProps[key], key, i.toString(), mutations, group.created);
210
+ groupSCD[key] = changes;
211
+ const scdTable = scdTableData
212
+ .filter(hookArr => hookArr.scdKey === key);
213
+
214
+ await config.hook(changes, 'scd-pre', { profile: group, type: groupKey, scd: { [key]: groupSCDs[key] }, config, allSCDs: groupSCD });
215
+ await scdTable[0].hookPush(changes, { profile: group, type: groupKey });
216
+ }
217
+
218
+
174
219
  }
175
220
  }
176
221
  log("\n");
177
222
 
223
+ //GROUP EVENTS
224
+ if (config.groupEvents) {
225
+ for (const groupEvent of config.groupEvents) {
226
+ const { frequency, group_key, attribute_to_user, group_size, ...normalEvent } = groupEvent;
227
+ for (const group_num of Array.from({ length: group_size }, (_, i) => i + 1)) {
228
+ const groupProfile = groupProfilesData.find(groups => groups.groupKey === group_key).find(group => group[group_key] === group_num);
229
+ const { created, distinct_id } = groupProfile;
230
+ normalEvent[group_key] = distinct_id;
231
+ const random_user_id = chance.pick(eventData.filter(a => a.user_id)).user_id;
232
+ if (!random_user_id) debugger;
233
+ const deltaDays = actualNow.diff(dayjs(created), "day");
234
+ const numIntervals = Math.floor(deltaDays / frequency);
235
+ const eventsForThisGroup = [];
236
+ for (let i = 0; i < numIntervals; i++) {
237
+ const event = await makeEvent(random_user_id, null, normalEvent, [], [], {}, [], false, true);
238
+ if (!attribute_to_user) delete event.user_id;
239
+ event[group_key] = distinct_id;
240
+ event.time = dayjs(created).add(i * frequency, "day").toISOString();
241
+ delete event.distinct_id;
242
+ //always skip the first event
243
+ if (i !== 0) {
244
+ eventsForThisGroup.push(event);
245
+ }
246
+ }
247
+ await groupEventData.hookPush(eventsForThisGroup, { profile: groupProfile });
248
+ }
249
+ }
250
+ }
251
+
252
+
178
253
  //LOOKUP TABLES
179
254
  for (const [index, lookupTable] of lookupTables.entries()) {
180
255
  const { key, entries, attributes } = lookupTable;
@@ -200,14 +275,14 @@ async function main(config) {
200
275
  log(`---------------SIMULATION----------------`, "\n");
201
276
 
202
277
  // draw charts
203
- const { makeChart } = config;
278
+ const { makeChart = false } = config;
204
279
  if (makeChart) {
205
280
  const bornEvents = config.events?.filter((e) => e?.isFirstEvent)?.map(e => e.event) || [];
206
281
  const bornFunnels = config.funnels?.filter((f) => f.isFirstFunnel)?.map(f => f.sequence[0]) || [];
207
282
  const bornBehaviors = [...bornEvents, ...bornFunnels];
208
283
  const chart = await generateLineChart(eventData, bornBehaviors, makeChart);
209
284
  }
210
- const { writeToDisk, token } = config;
285
+ const { writeToDisk = true, token } = config;
211
286
  if (!writeToDisk && !token) {
212
287
  jobTimer.stop(false);
213
288
  const { start, end, delta, human } = jobTimer.report(false);
@@ -246,6 +321,7 @@ async function main(config) {
246
321
  jobTimer.stop(false);
247
322
  const { start, end, delta, human } = jobTimer.report(false);
248
323
 
324
+ if (process.env.NODE_ENV === 'dev') debugger;
249
325
  return {
250
326
  ...STORAGE,
251
327
  importResults,
@@ -256,6 +332,53 @@ async function main(config) {
256
332
 
257
333
 
258
334
 
335
+ functions.http('entry', async (req, res) => {
336
+ const reqTimer = timer('request');
337
+ reqTimer.start();
338
+ let response = {};
339
+ let script = req.body || "";
340
+ let writePath;
341
+ try {
342
+ sLog("DM4: start");
343
+ if (!script) throw new Error("no script");
344
+
345
+ // Replace require("../ with require("./
346
+ script = script.replace(/require\("\.\.\//g, 'require("./');
347
+ // ^ need to replace this because of the way the script is passed in... this is sketch
348
+
349
+ /** @type {Config} */
350
+ const config = eval(script);
351
+ sLog("DM4: eval ok");
352
+
353
+ const { token } = config;
354
+ if (!token) throw new Error("no token");
355
+
356
+ /** @type {Config} */
357
+ const optionsYouCantChange = {
358
+ verbose: false,
359
+ writeToDisk: false,
360
+
361
+ };
362
+ const result = await main({
363
+ ...config,
364
+ ...optionsYouCantChange,
365
+ });
366
+ await rm(writePath);
367
+ reqTimer.stop(false);
368
+ const { start, end, delta, human } = jobTimer.report(false);
369
+ sLog(`DM4: end (${human})`, { ms: delta });
370
+ }
371
+ catch (e) {
372
+ sLog("DM4: error", { error: e.message });
373
+ response = { error: e.message };
374
+ res.status(500);
375
+ await rm(writePath);
376
+ }
377
+ finally {
378
+ res.send(response);
379
+ }
380
+ });
381
+
259
382
 
260
383
  /*
261
384
  ------
@@ -275,7 +398,7 @@ MODELS
275
398
  * @param {Boolean} [isFirstEvent]
276
399
  * @return {Promise<EventSchema>}
277
400
  */
278
- async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, sessionIds, superProps, groupKeys, isFirstEvent) {
401
+ async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, sessionIds, superProps, groupKeys, isFirstEvent, skipDefaults = false) {
279
402
  operations++;
280
403
  eventCount++;
281
404
  if (!distinct_id) throw new Error("no distinct_id");
@@ -307,11 +430,13 @@ async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, s
307
430
 
308
431
  let defaultProps = {};
309
432
  let devicePool = [];
433
+
310
434
  if (hasLocation) defaultProps.location = DEFAULTS.locationsEvents();
311
435
  if (hasBrowser) defaultProps.browser = DEFAULTS.browsers();
312
436
  if (hasAndroidDevices) devicePool.push(DEFAULTS.androidDevices());
313
437
  if (hasIOSDevices) devicePool.push(DEFAULTS.iOSDevices());
314
438
  if (hasDesktopDevices) devicePool.push(DEFAULTS.desktopDevices());
439
+
315
440
  // we don't always have campaigns, because of attribution
316
441
  if (hasCampaigns && chance.bool({ likelihood: 25 })) defaultProps.campaigns = DEFAULTS.campaigns();
317
442
  const devices = devicePool.flat();
@@ -319,13 +444,10 @@ async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, s
319
444
 
320
445
 
321
446
  //event time
322
- // if (earliestTime > FIXED_NOW) {
323
- // earliestTime = dayjs(u.TimeSoup(global.FIXED_BEGIN)).unix();
324
- // };
325
-
326
- if (isFirstEvent) eventTemplate.time = dayjs.unix(earliestTime).toISOString();
327
- if (!isFirstEvent) eventTemplate.time = u.TimeSoup(earliestTime, FIXED_NOW, peaks, deviation, mean);
328
- // eventTemplate.time = u.TimeSoup(earliestTime, FIXED_NOW, peaks, deviation, mean);
447
+ if (earliestTime) {
448
+ if (isFirstEvent) eventTemplate.time = dayjs.unix(earliestTime).toISOString();
449
+ if (!isFirstEvent) eventTemplate.time = u.TimeSoup(earliestTime, FIXED_NOW, peaks, deviation, mean);
450
+ }
329
451
 
330
452
  // anonymous and session ids
331
453
  if (anonymousIds.length) eventTemplate.device_id = chance.pickone(anonymousIds);
@@ -350,39 +472,39 @@ async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, s
350
472
  }
351
473
 
352
474
  //iterate through default properties
353
- for (const key in defaultProps) {
354
- if (Array.isArray(defaultProps[key])) {
355
- const choice = u.choose(defaultProps[key]);
356
- if (typeof choice === "string") {
357
- if (!eventTemplate[key]) eventTemplate[key] = choice;
358
- }
359
-
360
- else if (Array.isArray(choice)) {
361
- for (const subChoice of choice) {
362
- if (!eventTemplate[key]) eventTemplate[key] = subChoice;
475
+ if (!skipDefaults) {
476
+ for (const key in defaultProps) {
477
+ if (Array.isArray(defaultProps[key])) {
478
+ const choice = u.choose(defaultProps[key]);
479
+ if (typeof choice === "string") {
480
+ if (!eventTemplate[key]) eventTemplate[key] = choice;
363
481
  }
364
- }
365
482
 
366
- else if (typeof choice === "object") {
367
- for (const subKey in choice) {
368
- if (typeof choice[subKey] === "string") {
369
- if (!eventTemplate[subKey]) eventTemplate[subKey] = choice[subKey];
370
- }
371
- else if (Array.isArray(choice[subKey])) {
372
- const subChoice = u.choose(choice[subKey]);
373
- if (!eventTemplate[subKey]) eventTemplate[subKey] = subChoice;
483
+ else if (Array.isArray(choice)) {
484
+ for (const subChoice of choice) {
485
+ if (!eventTemplate[key]) eventTemplate[key] = subChoice;
374
486
  }
487
+ }
375
488
 
376
- else if (typeof choice[subKey] === "object") {
377
- for (const subSubKey in choice[subKey]) {
378
- if (!eventTemplate[subSubKey]) eventTemplate[subSubKey] = choice[subKey][subSubKey];
489
+ else if (typeof choice === "object") {
490
+ for (const subKey in choice) {
491
+ if (typeof choice[subKey] === "string") {
492
+ if (!eventTemplate[subKey]) eventTemplate[subKey] = choice[subKey];
493
+ }
494
+ else if (Array.isArray(choice[subKey])) {
495
+ const subChoice = u.choose(choice[subKey]);
496
+ if (!eventTemplate[subKey]) eventTemplate[subKey] = subChoice;
497
+ }
498
+
499
+ else if (typeof choice[subKey] === "object") {
500
+ for (const subSubKey in choice[subKey]) {
501
+ if (!eventTemplate[subSubKey]) eventTemplate[subSubKey] = choice[subKey][subSubKey];
502
+ }
379
503
  }
380
- }
381
504
 
505
+ }
382
506
  }
383
507
  }
384
-
385
-
386
508
  }
387
509
  }
388
510
 
@@ -401,8 +523,10 @@ async function makeEvent(distinct_id, earliestTime, chosenEvent, anonymousIds, s
401
523
  eventTemplate.insert_id = md5(JSON.stringify(eventTemplate));
402
524
 
403
525
  // move time forward
404
- const timeShifted = dayjs(eventTemplate.time).add(timeShift, "seconds").toISOString();
405
- eventTemplate.time = timeShifted;
526
+ if (earliestTime) {
527
+ const timeShifted = dayjs(eventTemplate.time).add(timeShift, "seconds").toISOString();
528
+ eventTemplate.time = timeShifted;
529
+ }
406
530
 
407
531
 
408
532
  return eventTemplate;
@@ -492,6 +616,8 @@ async function makeFunnel(funnel, user, firstEventTime, profile, scd, config) {
492
616
  return acc;
493
617
  }, []);
494
618
 
619
+ if (conversionRate > 100) conversionRate = 100;
620
+ if (conversionRate < 0) conversionRate = 0;
495
621
  let doesUserConvert = chance.bool({ likelihood: conversionRate });
496
622
  let numStepsUserWillTake = sequence.length;
497
623
  if (!doesUserConvert) numStepsUserWillTake = u.integer(1, sequence.length - 1);
@@ -604,12 +730,23 @@ async function makeProfile(props, defaults) {
604
730
  ...defaults,
605
731
  };
606
732
 
733
+ for (const key in profile) {
734
+ try {
735
+ profile[key] = u.choose(profile[key]);
736
+ }
737
+ catch (e) {
738
+ // never gets here
739
+ debugger;
740
+ }
741
+ }
742
+
743
+
607
744
  for (const key in props) {
608
745
  try {
609
746
  profile[key] = u.choose(props[key]);
610
747
  } catch (e) {
611
748
  // never gets here
612
- // debugger;
749
+ debugger;
613
750
  }
614
751
  }
615
752
 
@@ -617,42 +754,67 @@ async function makeProfile(props, defaults) {
617
754
  }
618
755
 
619
756
  /**
620
- * @param {ValueValid} prop
757
+ * @param {SCDProp} scdProp
621
758
  * @param {string} scdKey
622
759
  * @param {string} distinct_id
623
760
  * @param {number} mutations
624
761
  * @param {string} created
625
762
  * @return {Promise<SCDSchema[]>}
626
763
  */
627
- async function makeSCD(prop, scdKey, distinct_id, mutations, created) {
628
- if (JSON.stringify(prop) === "{}" || JSON.stringify(prop) === "[]") return [];
764
+ async function makeSCD(scdProp, scdKey, distinct_id, mutations, created) {
765
+ if (Array.isArray(scdProp)) scdProp = { values: scdProp, frequency: 'week', max: 10, timing: 'fuzzy', type: 'user' };
766
+ const { frequency, max, timing, values, type } = scdProp;
767
+ if (JSON.stringify(values) === "{}" || JSON.stringify(values) === "[]") return [];
629
768
  const scdEntries = [];
630
769
  let lastInserted = dayjs(created);
631
770
  const deltaDays = dayjs().diff(lastInserted, "day");
771
+ const uuidKeyName = type === 'user' ? 'distinct_id' : type;
632
772
 
633
773
  for (let i = 0; i < mutations; i++) {
634
774
  if (lastInserted.isAfter(dayjs())) break;
635
- let scd = await makeProfile({ [scdKey]: prop }, { distinct_id });
775
+ let scd = await makeProfile({ [scdKey]: values }, { [uuidKeyName]: distinct_id });
636
776
 
637
777
  // Explicitly constructing SCDSchema object with all required properties
638
778
  const scdEntry = {
639
779
  ...scd, // spread existing properties
640
- distinct_id: scd.distinct_id || distinct_id, // ensure distinct_id is set
641
- insertTime: lastInserted.add(u.integer(1, 1000), "seconds").toISOString(),
642
- startTime: lastInserted.toISOString()
780
+ [uuidKeyName]: scd.distinct_id || distinct_id, // ensure distinct_id is set
781
+ startTime: null,
782
+ insertTime: null
643
783
  };
644
784
 
785
+ if (timing === 'fixed') {
786
+ if (frequency === "day") scdEntry.startTime = lastInserted.add(1, "day").startOf('day').toISOString();
787
+ if (frequency === "week") scdEntry.startTime = lastInserted.add(1, "week").startOf('week').toISOString();
788
+ if (frequency === "month") scdEntry.startTime = lastInserted.add(1, "month").startOf('month').toISOString();
789
+ }
790
+
791
+ if (timing === 'fuzzy') {
792
+ scdEntry.startTime = lastInserted.toISOString();
793
+ }
794
+
795
+ const insertTime = lastInserted.add(u.integer(1, 9000), "seconds");
796
+ scdEntry.insertTime = insertTime.toISOString();
797
+
798
+
799
+
645
800
  // Ensure TypeScript sees all required properties are set
646
801
  if (scdEntry.hasOwnProperty('insertTime') && scdEntry.hasOwnProperty('startTime')) {
647
802
  scdEntries.push(scdEntry);
648
803
  }
649
804
 
805
+ //advance time for next entry
650
806
  lastInserted = lastInserted
651
807
  .add(u.integer(0, deltaDays), "day")
652
- .subtract(u.integer(1, 1000), "seconds");
808
+ .subtract(u.integer(1, 9000), "seconds");
653
809
  }
654
810
 
655
- return scdEntries;
811
+ //de-dupe on startTime
812
+ const deduped = scdEntries.filter((entry, index, self) =>
813
+ index === self.findIndex((t) => (
814
+ t.startTime === entry.startTime
815
+ ))
816
+ );
817
+ return deduped;
656
818
  }
657
819
 
658
820
 
@@ -806,24 +968,27 @@ async function userLoop(config, storage, concurrency = 1) {
806
968
  userProps,
807
969
  scdProps,
808
970
  numDays,
971
+ percentUsersBornInDataset = 5,
809
972
  } = config;
810
973
  const { eventData, userProfilesData, scdTableData } = storage;
811
974
  const avgEvPerUser = numEvents / numUsers;
975
+ const startTime = Date.now();
812
976
 
813
977
  for (let i = 0; i < numUsers; i++) {
814
978
  await USER_CONN(async () => {
815
979
  userCount++;
816
- if (verbose) u.progress([["users", userCount], ["events", eventCount]]);
980
+ const eps = Math.floor(eventCount / ((Date.now() - startTime) / 1000));
981
+ if (verbose) u.progress([["users", userCount], ["events", eventCount], ["eps", eps]]);
817
982
  const userId = chance.guid();
818
983
  const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds });
819
984
  const { distinct_id, created } = user;
820
- const userIsBornInDataset = chance.bool({ likelihood: 5 });
985
+ const userIsBornInDataset = chance.bool({ likelihood: percentUsersBornInDataset });
821
986
  let numEventsPreformed = 0;
822
987
  if (!userIsBornInDataset) delete user.created;
823
988
  const adjustedCreated = userIsBornInDataset ? dayjs(created).subtract(daysShift, 'd') : dayjs.unix(global.FIXED_BEGIN);
824
989
 
825
990
  if (hasLocation) {
826
- const location = u.choose(DEFAULTS.locationsUsers);
991
+ const location = u.shuffleArray(u.choose(DEFAULTS.locationsUsers)).pop();
827
992
  for (const key in location) {
828
993
  user[key] = location[key];
829
994
  }
@@ -831,16 +996,21 @@ async function userLoop(config, storage, concurrency = 1) {
831
996
 
832
997
  // Profile creation
833
998
  const profile = await makeProfile(userProps, user);
834
- await userProfilesData.hookPush(profile);
999
+
835
1000
 
836
1001
  // SCD creation
837
- const scdTableKeys = Object.keys(scdProps);
1002
+ const scdUserTables = t.objFilter(scdProps, (scd) => scd.type === 'user');
1003
+ const scdTableKeys = Object.keys(scdUserTables);
1004
+
1005
+
838
1006
  const userSCD = {};
839
1007
  for (const [index, key] of scdTableKeys.entries()) {
840
- const mutations = chance.integer({ min: 1, max: 10 }); //todo: configurable mutations?
1008
+ // @ts-ignore
1009
+ const { max = 100 } = scdProps[key];
1010
+ const mutations = chance.integer({ min: 1, max });
841
1011
  const changes = await makeSCD(scdProps[key], key, distinct_id, mutations, created);
842
1012
  userSCD[key] = changes;
843
- await scdTableData[index].hookPush(changes);
1013
+ await config.hook(changes, "scd-pre", { profile, type: 'user', scd: { [key]: scdProps[key] }, config, allSCDs: userSCD });
844
1014
  }
845
1015
 
846
1016
  let numEventsThisUserWillPreform = Math.floor(chance.normal({
@@ -860,6 +1030,7 @@ async function userLoop(config, storage, concurrency = 1) {
860
1030
 
861
1031
  const secondsInDay = 86400;
862
1032
  const noise = () => chance.integer({ min: 0, max: secondsInDay });
1033
+ let usersEvents = [];
863
1034
 
864
1035
  if (firstFunnels.length && userIsBornInDataset) {
865
1036
  const firstFunnel = chance.pickone(firstFunnels, user);
@@ -868,7 +1039,8 @@ async function userLoop(config, storage, concurrency = 1) {
868
1039
  const [data, userConverted] = await makeFunnel(firstFunnel, user, firstTime, profile, userSCD, config);
869
1040
  userFirstEventTime = dayjs(data[0].time).subtract(timeShift, 'seconds').unix();
870
1041
  numEventsPreformed += data.length;
871
- await eventData.hookPush(data);
1042
+ // await eventData.hookPush(data, { profile });
1043
+ usersEvents.push(...data);
872
1044
  if (!userConverted) {
873
1045
  if (verbose) u.progress([["users", userCount], ["events", eventCount]]);
874
1046
  return;
@@ -884,14 +1056,35 @@ async function userLoop(config, storage, concurrency = 1) {
884
1056
  const currentFunnel = chance.pickone(usageFunnels);
885
1057
  const [data, userConverted] = await makeFunnel(currentFunnel, user, userFirstEventTime, profile, userSCD, config);
886
1058
  numEventsPreformed += data.length;
887
- await eventData.hookPush(data);
1059
+ usersEvents.push(...data);
1060
+ // await eventData.hookPush(data, { profile });
888
1061
  } else {
889
1062
  const data = await makeEvent(distinct_id, userFirstEventTime, u.choose(config.events), user.anonymousIds, user.sessionIds, {}, config.groupKeys, true);
890
1063
  numEventsPreformed++;
891
- await eventData.hookPush(data);
1064
+ usersEvents.push(data);
1065
+ // await eventData.hookPush(data);
892
1066
  }
893
1067
  }
894
1068
 
1069
+ // NOW ADD ALL OUR DATA FOR THIS USER
1070
+ if (config.hook) {
1071
+ const newEvents = await config.hook(usersEvents, "everything", { profile, scd: userSCD, config, userIsBornInDataset });
1072
+ if (Array.isArray(newEvents)) usersEvents = newEvents;
1073
+ }
1074
+
1075
+ await userProfilesData.hookPush(profile);
1076
+
1077
+ if (Object.keys(userSCD).length) {
1078
+ for (const [key, changesArray] of Object.entries(userSCD)) {
1079
+ for (const changes of changesArray) {
1080
+ const target = scdTableData.filter(arr => arr.scdKey === key).pop();
1081
+ await target.hookPush(changes, { profile, type: 'user' });
1082
+ }
1083
+ }
1084
+ }
1085
+ await eventData.hookPush(usersEvents, { profile });
1086
+
1087
+
895
1088
  if (verbose) u.progress([["users", userCount], ["events", eventCount]]);
896
1089
  });
897
1090
  }
@@ -906,8 +1099,18 @@ async function userLoop(config, storage, concurrency = 1) {
906
1099
  * @param {Storage} storage
907
1100
  */
908
1101
  async function sendToMixpanel(config, storage) {
909
- const { adSpendData, eventData, groupProfilesData, lookupTableData, mirrorEventData, scdTableData, userProfilesData } = storage;
910
- const { token, region, writeToDisk } = config;
1102
+ const {
1103
+ adSpendData,
1104
+ eventData,
1105
+ groupProfilesData,
1106
+ lookupTableData,
1107
+ mirrorEventData,
1108
+ scdTableData,
1109
+ userProfilesData,
1110
+ groupEventData
1111
+
1112
+ } = storage;
1113
+ const { token, region, writeToDisk = true } = config;
911
1114
  const importResults = { events: {}, users: {}, groups: [] };
912
1115
 
913
1116
  /** @type {import('mixpanel-import').Creds} */
@@ -924,7 +1127,7 @@ async function sendToMixpanel(config, storage) {
924
1127
  dryRun: false,
925
1128
  abridged: false,
926
1129
  fixJson: true,
927
- showProgress: true,
1130
+ showProgress: NODE_ENV === "dev" ? true : false,
928
1131
  streamFormat: mpImportFormat
929
1132
  };
930
1133
 
@@ -960,7 +1163,7 @@ async function sendToMixpanel(config, storage) {
960
1163
  log(`\tsent ${comma(imported.success)} user profiles\n`);
961
1164
  importResults.users = imported;
962
1165
  }
963
- if (adSpendData || isBATCH_MODE) {
1166
+ if (groupEventData || isBATCH_MODE) {
964
1167
  log(`importing ad spend data to mixpanel...\n`);
965
1168
  let adSpendDataToImport = clone(adSpendData);
966
1169
  if (isBATCH_MODE) {
@@ -996,11 +1199,65 @@ async function sendToMixpanel(config, storage) {
996
1199
  }
997
1200
  }
998
1201
 
1202
+ if (groupEventData || isBATCH_MODE) {
1203
+ log(`importing group events to mixpanel...\n`);
1204
+ let groupEventDataToImport = clone(groupEventData);
1205
+ if (isBATCH_MODE) {
1206
+ const writeDir = groupEventData.getWriteDir();
1207
+ const files = await ls(writeDir.split(path.basename(writeDir)).join(""));
1208
+ groupEventDataToImport = files.filter(f => f.includes('-GROUP-EVENTS-'));
1209
+ }
1210
+ const imported = await mp(creds, groupEventDataToImport, {
1211
+ recordType: "event",
1212
+ ...commonOpts,
1213
+ strict: false
1214
+ });
1215
+ log(`\tsent ${comma(imported.success)} group events\n`);
1216
+ importResults.groupEvents = imported;
1217
+ }
1218
+ const { serviceAccount, projectId, serviceSecret } = config;
1219
+ if (serviceAccount && projectId && serviceSecret) {
1220
+ if (scdTableData || isBATCH_MODE) {
1221
+ log(`importing SCD data to mixpanel...\n`);
1222
+ for (const scdEntity of scdTableData) {
1223
+ const scdKey = scdEntity?.scdKey;
1224
+ log(`importing ${scdKey} SCD data to mixpanel...\n`);
1225
+ let scdDataToImport = clone(scdEntity);
1226
+ if (isBATCH_MODE) {
1227
+ const writeDir = scdEntity.getWriteDir();
1228
+ const files = await ls(writeDir.split(path.basename(writeDir)).join(""));
1229
+ scdDataToImport = files.filter(f => f.includes(`-SCD-${scdKey}`));
1230
+ }
1231
+
1232
+ const options = {
1233
+ recordType: "scd",
1234
+ scdKey,
1235
+ scdType: scdEntity.dataType,
1236
+ scdLabel: `${scdKey}-scd`,
1237
+ ...commonOpts,
1238
+ };
1239
+ if (scdEntity.entityType !== "user") options.groupKey = scdEntity.entityType;
1240
+ const imported = await mp(
1241
+ {
1242
+ token,
1243
+ acct: serviceAccount,
1244
+ pass: serviceSecret,
1245
+ project: projectId
1246
+ },
1247
+ scdDataToImport,
1248
+ // @ts-ignore
1249
+ options);
1250
+ log(`\tsent ${comma(imported.success)} ${scdKey} SCD data\n`);
1251
+ importResults[`${scdKey}_scd`] = imported;
1252
+ }
1253
+ }
1254
+ }
1255
+
999
1256
  //if we are in batch mode, we need to delete the files
1000
1257
  if (!writeToDisk && isBATCH_MODE) {
1001
1258
  const writeDir = eventData?.getWriteDir() || userProfilesData?.getWriteDir();
1002
1259
  const listDir = await ls(writeDir.split(path.basename(writeDir)).join(""));
1003
- const files = listDir.filter(f => f.includes('-EVENTS-') || f.includes('-USERS-') || f.includes('-AD-SPEND-') || f.includes('-GROUPS-'));
1260
+ const files = listDir.filter(f => f.includes('-EVENTS-') || f.includes('-USERS-') || f.includes('-AD-SPEND-') || f.includes('-GROUPS-') || f.includes('-GROUP-EVENTS-'));
1004
1261
  for (const file of files) {
1005
1262
  await rm(file);
1006
1263
  }
@@ -1093,6 +1350,43 @@ function validateDungeonConfig(config) {
1093
1350
  funnels = [...funnels, ...inferredFunnels];
1094
1351
  }
1095
1352
 
1353
+
1354
+ const eventContainedInFunnels = Array.from(funnels.reduce((acc, f) => {
1355
+ const events = f.sequence;
1356
+ events.forEach(event => acc.add(event));
1357
+ return acc;
1358
+ }, new Set()));
1359
+
1360
+ const eventsNotInFunnels = events
1361
+ .filter(e => !e.isFirstEvent)
1362
+ .filter(e => !eventContainedInFunnels.includes(e.event)).map(e => e.event);
1363
+ if (eventsNotInFunnels.length) {
1364
+ // const biggestWeight = funnels.reduce((acc, f) => {
1365
+ // if (f.weight > acc) return f.weight;
1366
+ // return acc;
1367
+ // }, 0);
1368
+ // const smallestWeight = funnels.reduce((acc, f) => {
1369
+ // if (f.weight < acc) return f.weight;
1370
+ // return acc;
1371
+ // }, 0);
1372
+ // const weight = u.integer(smallestWeight, biggestWeight) * 2;
1373
+
1374
+ const sequence = u.shuffleArray(eventsNotInFunnels.flatMap(event => {
1375
+ const evWeight = config.events.find(e => e.event === event)?.weight || 1;
1376
+ return Array(evWeight).fill(event);
1377
+ }));
1378
+
1379
+
1380
+
1381
+ funnels.push({
1382
+ sequence,
1383
+ conversionRate: 50,
1384
+ order: 'random',
1385
+ timeToConvert: 24 * 14,
1386
+ requireRepeats: false,
1387
+ });
1388
+ }
1389
+
1096
1390
  config.concurrency = concurrency;
1097
1391
  config.funnels = funnels;
1098
1392
  config.batchSize = batchSize;
@@ -1156,6 +1450,8 @@ async function makeHookArray(arr = [], opts = {}) {
1156
1450
  if (existsSync(dataFolder)) writeDir = dataFolder;
1157
1451
  else writeDir = path.resolve("./");
1158
1452
 
1453
+ if (NODE_ENV === "prod") writeDir = path.resolve(os.tmpdir());
1454
+
1159
1455
  function getWritePath() {
1160
1456
  if (isBATCH_MODE) {
1161
1457
  return path.join(writeDir, `${filepath}-part-${batch.toString()}.${format}`);
@@ -1169,14 +1465,14 @@ async function makeHookArray(arr = [], opts = {}) {
1169
1465
  return path.join(writeDir, `${filepath}.${format}`);
1170
1466
  }
1171
1467
 
1172
- async function transformThenPush(item) {
1468
+ async function transformThenPush(item, meta) {
1173
1469
  if (item === null || item === undefined) return false;
1174
1470
  if (typeof item === 'object' && Object.keys(item).length === 0) return false;
1175
-
1471
+ const allMetaData = { ...rest, ...meta };
1176
1472
  if (Array.isArray(item)) {
1177
1473
  for (const i of item) {
1178
1474
  try {
1179
- const enriched = await hook(i, type, rest);
1475
+ const enriched = await hook(i, type, allMetaData);
1180
1476
  if (Array.isArray(enriched)) enriched.forEach(e => arr.push(e));
1181
1477
  else arr.push(enriched);
1182
1478
  } catch (e) {
@@ -1186,7 +1482,7 @@ async function makeHookArray(arr = [], opts = {}) {
1186
1482
  }
1187
1483
  } else {
1188
1484
  try {
1189
- const enriched = await hook(item, type, rest);
1485
+ const enriched = await hook(item, type, allMetaData);
1190
1486
  if (Array.isArray(enriched)) enriched.forEach(e => arr.push(e));
1191
1487
  else arr.push(enriched);
1192
1488
  } catch (e) {
@@ -1301,92 +1597,94 @@ CLI
1301
1597
  ----
1302
1598
  */
1303
1599
 
1304
- if (require.main === module) {
1305
- isCLI = true;
1306
- const args = /** @type {Config} */ (getCliParams());
1307
- let { token, seed, format, numDays, numUsers, numEvents, region, writeToDisk, complex = false, hasSessionIds, hasAnonIds } = args;
1308
- const suppliedConfig = args._[0];
1309
-
1310
- //if the user specifies an separate config file
1311
- let config = null;
1312
- if (suppliedConfig) {
1313
- console.log(`using ${suppliedConfig} for data\n`);
1314
- config = require(path.resolve(suppliedConfig));
1315
- }
1316
- else {
1317
- if (complex) {
1318
- console.log(`... using default COMPLEX configuration [everything] ...\n`);
1319
- console.log(`... for more simple data, don't use the --complex flag ...\n`);
1320
- console.log(`... or specify your own js config file (see docs or --help) ...\n`);
1321
- config = require(path.resolve(__dirname, "./schemas/complex.js"));
1600
+ if (NODE_ENV !== "prod") {
1601
+ if (require.main === module) {
1602
+ isCLI = true;
1603
+ const args = /** @type {Config} */ (getCliParams());
1604
+ let { token, seed, format, numDays, numUsers, numEvents, region, writeToDisk, complex = false, hasSessionIds, hasAnonIds } = args;
1605
+ const suppliedConfig = args._[0];
1606
+
1607
+ //if the user specifies an separate config file
1608
+ let config = null;
1609
+ if (suppliedConfig) {
1610
+ console.log(`using ${suppliedConfig} for data\n`);
1611
+ config = require(path.resolve(suppliedConfig));
1322
1612
  }
1323
1613
  else {
1324
- console.log(`... using default SIMPLE configuration [events + users] ...\n`);
1325
- console.log(`... for more complex data, use the --complex flag ...\n`);
1326
- config = require(path.resolve(__dirname, "./schemas/simple.js"));
1614
+ if (complex) {
1615
+ console.log(`... using default COMPLEX configuration [everything] ...\n`);
1616
+ console.log(`... for more simple data, don't use the --complex flag ...\n`);
1617
+ console.log(`... or specify your own js config file (see docs or --help) ...\n`);
1618
+ config = require(path.resolve(__dirname, "./dungeons/complex.js"));
1619
+ }
1620
+ else {
1621
+ console.log(`... using default SIMPLE configuration [events + users] ...\n`);
1622
+ console.log(`... for more complex data, use the --complex flag ...\n`);
1623
+ config = require(path.resolve(__dirname, "./dungeons/simple.js"));
1624
+ }
1327
1625
  }
1328
- }
1329
1626
 
1330
- //override config with cli params
1331
- if (token) config.token = token;
1332
- if (seed) config.seed = seed;
1333
- if (format === "csv" && config.format === "json") format = "json";
1334
- if (format) config.format = format;
1335
- if (numDays) config.numDays = numDays;
1336
- if (numUsers) config.numUsers = numUsers;
1337
- if (numEvents) config.numEvents = numEvents;
1338
- if (region) config.region = region;
1339
- if (writeToDisk) config.writeToDisk = writeToDisk;
1340
- if (writeToDisk === 'false') config.writeToDisk = false;
1341
- if (hasSessionIds) config.hasSessionIds = hasSessionIds;
1342
- if (hasAnonIds) config.hasAnonIds = hasAnonIds;
1343
- config.verbose = true;
1344
-
1345
- main(config)
1346
- .then((data) => {
1347
- //todo: rethink summary
1348
- log(`-----------------SUMMARY-----------------`);
1349
- const d = { success: 0, bytes: 0 };
1350
- const darr = [d];
1351
- const { events = d, groups = darr, users = d } = data?.importResults || {};
1352
- const files = data.files;
1353
- const folder = files?.[0]?.split(path.basename(files?.[0]))?.shift();
1354
- const groupBytes = groups.reduce((acc, group) => {
1355
- return acc + group.bytes;
1356
- }, 0);
1357
- const groupSuccess = groups.reduce((acc, group) => {
1358
- return acc + group.success;
1359
- }, 0);
1360
- const bytes = events.bytes + groupBytes + users.bytes;
1361
- const stats = {
1362
- events: comma(events.success || 0),
1363
- users: comma(users.success || 0),
1364
- groups: comma(groupSuccess || 0),
1365
- bytes: bytesHuman(bytes || 0),
1366
- };
1367
- if (bytes > 0) console.table(stats);
1368
- log(`\nfiles written to ${folder || "no where; we didn't write anything"} ...`);
1369
- log(" " + files?.flat().join("\n "));
1370
- log(`\n----------------SUMMARY-----------------\n\n\n`);
1371
- })
1372
- .catch((e) => {
1373
- log(`------------------ERROR------------------`);
1374
- console.error(e);
1375
- log(`------------------ERROR------------------`);
1376
- debugger;
1377
- })
1378
- .finally(() => {
1379
- log("enjoy your data! :)");
1380
- u.openFinder(path.resolve("./data"));
1381
- });
1382
- } else {
1383
- main.generators = { makeEvent, makeFunnel, makeProfile, makeSCD, makeAdSpend, makeMirror };
1384
- main.orchestrators = { userLoop, validateDungeonConfig, sendToMixpanel };
1385
- main.meta = { inferFunnels, hookArray: makeHookArray };
1386
- module.exports = main;
1627
+ //override config with cli params
1628
+ if (token) config.token = token;
1629
+ if (seed) config.seed = seed;
1630
+ if (format === "csv" && config.format === "json") format = "json";
1631
+ if (format) config.format = format;
1632
+ if (numDays) config.numDays = numDays;
1633
+ if (numUsers) config.numUsers = numUsers;
1634
+ if (numEvents) config.numEvents = numEvents;
1635
+ if (region) config.region = region;
1636
+ if (writeToDisk) config.writeToDisk = writeToDisk;
1637
+ if (writeToDisk === 'false') config.writeToDisk = false;
1638
+ if (hasSessionIds) config.hasSessionIds = hasSessionIds;
1639
+ if (hasAnonIds) config.hasAnonIds = hasAnonIds;
1640
+ config.verbose = true;
1641
+
1642
+ main(config)
1643
+ .then((data) => {
1644
+ log(`-----------------SUMMARY-----------------`);
1645
+ const d = { success: 0, bytes: 0 };
1646
+ const darr = [d];
1647
+ const { events = d, groups = darr, users = d } = data?.importResults || {};
1648
+ const files = data.files;
1649
+ const folder = files?.[0]?.split(path.basename(files?.[0]))?.shift() || "./data";
1650
+ const groupBytes = groups.reduce((acc, group) => {
1651
+ return acc + group.bytes;
1652
+ }, 0);
1653
+ const groupSuccess = groups.reduce((acc, group) => {
1654
+ return acc + group.success;
1655
+ }, 0);
1656
+ const bytes = events.bytes + groupBytes + users.bytes;
1657
+ const stats = {
1658
+ events: comma(events.success || 0),
1659
+ users: comma(users.success || 0),
1660
+ groups: comma(groupSuccess || 0),
1661
+ bytes: bytesHuman(bytes || 0),
1662
+ };
1663
+ if (bytes > 0) console.table(stats);
1664
+ log(`\nlog written to ${folder} ...`);
1665
+ writeFileSync(path.join(folder, "log.txt"), JSON.stringify(data?.importResults, null, 2));
1666
+ // log(" " + files?.flat().join("\n "));
1667
+ log(`\n----------------SUMMARY-----------------\n\n\n`);
1668
+ })
1669
+ .catch((e) => {
1670
+ log(`------------------ERROR------------------`);
1671
+ console.error(e);
1672
+ log(`------------------ERROR------------------`);
1673
+ debugger;
1674
+ })
1675
+ .finally(() => {
1676
+ log("enjoy your data! :)");
1677
+ });
1678
+ } else {
1679
+ main.generators = { makeEvent, makeFunnel, makeProfile, makeSCD, makeAdSpend, makeMirror };
1680
+ main.orchestrators = { userLoop, validateDungeonConfig, sendToMixpanel };
1681
+ main.meta = { inferFunnels, hookArray: makeHookArray };
1682
+ module.exports = main;
1683
+ }
1387
1684
  }
1388
1685
 
1389
1686
 
1687
+
1390
1688
  /*
1391
1689
  ----
1392
1690
  HELPERS
@@ -1411,7 +1709,7 @@ function track(name, props, ...rest) {
1411
1709
  }
1412
1710
 
1413
1711
 
1414
- /** @typedef {import('./types.js').Config} Config */
1712
+ /** @typedef {import('./types.js').Dungeon} Config */
1415
1713
  /** @typedef {import('./types.js').AllData} AllData */
1416
1714
  /** @typedef {import('./types.js').EventConfig} EventConfig */
1417
1715
  /** @typedef {import('./types.js').Funnel} Funnel */
@@ -1424,4 +1722,5 @@ function track(name, props, ...rest) {
1424
1722
  /** @typedef {import('./types.js').ValueValid} ValueValid */
1425
1723
  /** @typedef {import('./types.js').HookedArray} hookArray */
1426
1724
  /** @typedef {import('./types.js').hookArrayOptions} hookArrayOptions */
1427
- /** @typedef {import('./types.js').GroupProfileSchema} GroupProfile */
1725
+ /** @typedef {import('./types.js').GroupProfileSchema} GroupProfile */
1726
+ /** @typedef {import('./types.js').SCDProp} SCDProp */