@ak--47/dungeon-master 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/dungeons/array-of-object-lookup-schema.json +327 -0
- package/dungeons/array-of-object-lookup.js +220 -0
- package/dungeons/ecommerce-schema.json +462 -0
- package/dungeons/ecommerce.js +447 -0
- package/dungeons/education-schema.json +2409 -0
- package/dungeons/education.js +768 -0
- package/dungeons/fintech-schema.json +14034 -0
- package/dungeons/fintech.js +696 -0
- package/dungeons/foobar-schema.json +403 -0
- package/dungeons/foobar.js +296 -0
- package/dungeons/food-delivery-schema.json +192 -0
- package/dungeons/food-delivery.js +602 -0
- package/dungeons/food-schema.json +1152 -0
- package/dungeons/food.js +754 -0
- package/dungeons/gaming-schema.json +1270 -0
- package/dungeons/gaming.js +508 -0
- package/dungeons/insurance-application-schema.json +204 -0
- package/dungeons/insurance-application.js +605 -0
- package/dungeons/media-schema.json +906 -0
- package/dungeons/media.js +790 -0
- package/dungeons/retention-cadence-schema.json +78 -0
- package/dungeons/retention-cadence.js +244 -0
- package/dungeons/rpg-schema.json +4526 -0
- package/dungeons/rpg.js +919 -0
- package/dungeons/sanity-schema.json +255 -0
- package/dungeons/sanity.js +152 -0
- package/dungeons/sass-schema.json +1291 -0
- package/dungeons/sass.js +795 -0
- package/dungeons/scd-schema.json +919 -0
- package/dungeons/scd.js +277 -0
- package/dungeons/simple-schema.json +608 -0
- package/dungeons/simple.js +285 -0
- package/dungeons/simplest-schema.json +1418 -0
- package/dungeons/simplest.js +392 -0
- package/dungeons/social-schema.json +1118 -0
- package/dungeons/social.js +686 -0
- package/dungeons/text-generation-schema.json +3096 -0
- package/dungeons/text-generation.js +812 -0
- package/index.js +567 -0
- package/lib/core/config-validator.js +395 -0
- package/lib/core/context.js +204 -0
- package/lib/core/dungeon-loader.js +337 -0
- package/lib/core/storage.js +379 -0
- package/lib/generators/adspend.js +132 -0
- package/lib/generators/events.js +271 -0
- package/lib/generators/funnels.js +407 -0
- package/lib/generators/mirror.js +167 -0
- package/lib/generators/product-lookup.js +262 -0
- package/lib/generators/product-names.js +195 -0
- package/lib/generators/profiles.js +93 -0
- package/lib/generators/scd.js +124 -0
- package/lib/generators/text.js +1192 -0
- package/lib/orchestrators/mixpanel-sender.js +266 -0
- package/lib/orchestrators/user-loop.js +335 -0
- package/lib/templates/abbreviated.d.ts +169 -0
- package/lib/templates/defaults.js +1405 -0
- package/lib/templates/phrases.js +2526 -0
- package/lib/templates/schema.d.ts +173 -0
- package/lib/templates/soup-presets.js +188 -0
- package/lib/utils/function-registry.js +302 -0
- package/lib/utils/json-evaluator.js +172 -0
- package/lib/utils/logger.js +34 -0
- package/lib/utils/utils.js +1490 -0
- package/package.json +89 -0
- package/types.d.ts +865 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpanel Sender Orchestrator module
|
|
3
|
+
* Handles sending all data types to Mixpanel
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Context} Context */
|
|
7
|
+
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import { comma, ls, rm } from "ak-tools";
|
|
10
|
+
import * as u from "../utils/utils.js";
|
|
11
|
+
import mp from "mixpanel-import";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sends the data to Mixpanel
|
|
15
|
+
* @param {Context} context - Context object containing config, storage, etc.
|
|
16
|
+
* @returns {Promise<Object>} Import results for all data types
|
|
17
|
+
*/
|
|
18
|
+
export async function sendToMixpanel(context) {
|
|
19
|
+
const { config, storage } = context;
|
|
20
|
+
const {
|
|
21
|
+
adSpendData,
|
|
22
|
+
eventData,
|
|
23
|
+
groupProfilesData,
|
|
24
|
+
scdTableData,
|
|
25
|
+
userProfilesData,
|
|
26
|
+
groupEventData
|
|
27
|
+
} = storage;
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
token,
|
|
31
|
+
region,
|
|
32
|
+
writeToDisk = true,
|
|
33
|
+
format,
|
|
34
|
+
serviceAccount,
|
|
35
|
+
projectId,
|
|
36
|
+
serviceSecret
|
|
37
|
+
} = config;
|
|
38
|
+
|
|
39
|
+
const importResults = { events: {}, users: {}, groups: [] };
|
|
40
|
+
const isBATCH_MODE = context.isBatchMode();
|
|
41
|
+
_verbose = config.verbose !== false;
|
|
42
|
+
|
|
43
|
+
/** @type {import('mixpanel-import').Creds} */
|
|
44
|
+
const creds = { token };
|
|
45
|
+
const mpImportFormat = format === "json" ? "jsonl" : "csv";
|
|
46
|
+
|
|
47
|
+
/** @type {import('mixpanel-import').Options} */
|
|
48
|
+
const commonOpts = {
|
|
49
|
+
region,
|
|
50
|
+
fixData: true,
|
|
51
|
+
verbose: false,
|
|
52
|
+
forceStream: true,
|
|
53
|
+
strict: true,
|
|
54
|
+
epochEnd: dayjs().unix(),
|
|
55
|
+
dryRun: false,
|
|
56
|
+
abridged: false,
|
|
57
|
+
fixJson: false,
|
|
58
|
+
showProgress: !!config.verbose,
|
|
59
|
+
streamFormat: mpImportFormat,
|
|
60
|
+
workers: 35
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
log(`\n${'─'.repeat(50)}`);
|
|
64
|
+
log(` Importing data to Mixpanel (${region})`);
|
|
65
|
+
log(`${'─'.repeat(50)}\n`);
|
|
66
|
+
|
|
67
|
+
// Import events
|
|
68
|
+
if (eventData?.length > 0 || isBATCH_MODE) {
|
|
69
|
+
log(` Events`);
|
|
70
|
+
let eventDataToImport = u.deepClone(eventData);
|
|
71
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && eventData && eventData.length === 0);
|
|
72
|
+
if (shouldReadFromFiles && eventData?.getWriteDir) {
|
|
73
|
+
const writeDir = eventData.getWriteDir();
|
|
74
|
+
const files = await ls(writeDir);
|
|
75
|
+
// @ts-ignore
|
|
76
|
+
eventDataToImport = files.filter(f => f.includes('-EVENTS'));
|
|
77
|
+
}
|
|
78
|
+
const imported = await mp(creds, eventDataToImport, {
|
|
79
|
+
recordType: "event",
|
|
80
|
+
...commonOpts,
|
|
81
|
+
});
|
|
82
|
+
log(` -> ${comma(imported.success)} events sent\n`);
|
|
83
|
+
importResults.events = imported;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Import user profiles
|
|
87
|
+
if (userProfilesData?.length > 0 || isBATCH_MODE) {
|
|
88
|
+
log(` User Profiles`);
|
|
89
|
+
let userProfilesToImport = u.deepClone(userProfilesData);
|
|
90
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && userProfilesData && userProfilesData.length === 0);
|
|
91
|
+
if (shouldReadFromFiles && userProfilesData?.getWriteDir) {
|
|
92
|
+
const writeDir = userProfilesData.getWriteDir();
|
|
93
|
+
const files = await ls(writeDir);
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
userProfilesToImport = files.filter(f => f.includes('-USERS'));
|
|
96
|
+
}
|
|
97
|
+
const imported = await mp(creds, userProfilesToImport, {
|
|
98
|
+
recordType: "user",
|
|
99
|
+
...commonOpts,
|
|
100
|
+
});
|
|
101
|
+
log(` -> ${comma(imported.success)} user profiles sent\n`);
|
|
102
|
+
importResults.users = imported;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Import ad spend data
|
|
106
|
+
if (adSpendData?.length > 0 || isBATCH_MODE) {
|
|
107
|
+
log(` Ad Spend`);
|
|
108
|
+
let adSpendDataToImport = u.deepClone(adSpendData);
|
|
109
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && adSpendData && adSpendData.length === 0);
|
|
110
|
+
if (shouldReadFromFiles && adSpendData?.getWriteDir) {
|
|
111
|
+
const writeDir = adSpendData.getWriteDir();
|
|
112
|
+
const files = await ls(writeDir);
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
adSpendDataToImport = files.filter(f => f.includes('-ADSPEND'));
|
|
115
|
+
}
|
|
116
|
+
const imported = await mp(creds, adSpendDataToImport, {
|
|
117
|
+
recordType: "event",
|
|
118
|
+
...commonOpts,
|
|
119
|
+
});
|
|
120
|
+
log(` -> ${comma(imported.success)} ad spend events sent\n`);
|
|
121
|
+
importResults.adSpend = imported;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Import group profiles
|
|
125
|
+
if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
|
|
126
|
+
for (const groupEntity of groupProfilesData) {
|
|
127
|
+
if (!groupEntity || groupEntity.length === 0) continue;
|
|
128
|
+
const groupKey = groupEntity?.groupKey;
|
|
129
|
+
log(` Group Profiles (${groupKey})`);
|
|
130
|
+
let groupProfilesToImport = u.deepClone(groupEntity);
|
|
131
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEntity.length === 0);
|
|
132
|
+
if (shouldReadFromFiles && groupEntity?.getWriteDir) {
|
|
133
|
+
const writeDir = groupEntity.getWriteDir();
|
|
134
|
+
const files = await ls(writeDir);
|
|
135
|
+
// @ts-ignore
|
|
136
|
+
groupProfilesToImport = files.filter(f => f.includes(`-${groupKey}-GROUPS`));
|
|
137
|
+
}
|
|
138
|
+
const imported = await mp({ token, groupKey }, groupProfilesToImport, {
|
|
139
|
+
recordType: "group",
|
|
140
|
+
...commonOpts,
|
|
141
|
+
groupKey,
|
|
142
|
+
});
|
|
143
|
+
log(` -> ${comma(imported.success)} ${groupKey} profiles sent\n`);
|
|
144
|
+
importResults.groups.push(imported);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Import group events
|
|
149
|
+
if (groupEventData?.length > 0) {
|
|
150
|
+
log(` Group Events`);
|
|
151
|
+
let groupEventDataToImport = u.deepClone(groupEventData);
|
|
152
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
|
|
153
|
+
if (shouldReadFromFiles && groupEventData?.getWriteDir) {
|
|
154
|
+
const writeDir = groupEventData.getWriteDir();
|
|
155
|
+
const files = await ls(writeDir);
|
|
156
|
+
// @ts-ignore
|
|
157
|
+
groupEventDataToImport = files.filter(f => f.includes('-GROUP-EVENTS'));
|
|
158
|
+
}
|
|
159
|
+
const imported = await mp(creds, groupEventDataToImport, {
|
|
160
|
+
recordType: "event",
|
|
161
|
+
...commonOpts,
|
|
162
|
+
strict: false
|
|
163
|
+
});
|
|
164
|
+
log(` -> ${comma(imported.success)} group events sent\n`);
|
|
165
|
+
importResults.groupEvents = imported;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Import SCD data (requires service account)
|
|
169
|
+
if (serviceAccount && projectId && serviceSecret) {
|
|
170
|
+
if (scdTableData && Array.isArray(scdTableData) && scdTableData.length > 0) {
|
|
171
|
+
for (const scdEntity of scdTableData) {
|
|
172
|
+
const scdKey = scdEntity?.scdKey;
|
|
173
|
+
const entityType = scdEntity?.entityType || 'user';
|
|
174
|
+
log(` SCD: ${scdKey}`);
|
|
175
|
+
let scdDataToImport = u.deepClone(scdEntity);
|
|
176
|
+
const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && scdEntity && scdEntity.length === 0);
|
|
177
|
+
if (shouldReadFromFiles && scdEntity?.getWriteDir) {
|
|
178
|
+
const writeDir = scdEntity.getWriteDir();
|
|
179
|
+
const files = await ls(writeDir);
|
|
180
|
+
// @ts-ignore
|
|
181
|
+
scdDataToImport = files.filter(f => f.includes(`-${scdKey}-SCD`))?.pop();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @type {"string" | "number" | "boolean"} */
|
|
185
|
+
let scdType = 'string';
|
|
186
|
+
const scdExamplesValues = context.config.scdProps[Object.keys(context.config.scdProps).find(k => k === scdKey)].values;
|
|
187
|
+
if (scdExamplesValues) {
|
|
188
|
+
if (typeof scdExamplesValues[0] === 'number') {
|
|
189
|
+
scdType = 'number';
|
|
190
|
+
} else if (typeof scdExamplesValues[0] === 'boolean') {
|
|
191
|
+
scdType = 'boolean';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @type {import('mixpanel-import').Options} */
|
|
196
|
+
const options = {
|
|
197
|
+
recordType: "scd",
|
|
198
|
+
scdKey,
|
|
199
|
+
scdType,
|
|
200
|
+
scdLabel: `${scdKey}`,
|
|
201
|
+
fixData: true,
|
|
202
|
+
...commonOpts,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (entityType !== "user") {
|
|
206
|
+
options.groupKey = entityType;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const imported = await mp(
|
|
211
|
+
{
|
|
212
|
+
token,
|
|
213
|
+
acct: serviceAccount,
|
|
214
|
+
pass: serviceSecret,
|
|
215
|
+
project: projectId
|
|
216
|
+
},
|
|
217
|
+
scdDataToImport,
|
|
218
|
+
options
|
|
219
|
+
);
|
|
220
|
+
log(` -> ${comma(imported.success)} ${scdKey} SCD entries sent\n`);
|
|
221
|
+
importResults[`${scdKey}_scd`] = imported;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
log(` !! failed: ${scdKey} SCD — ${err.message}\n`);
|
|
224
|
+
importResults[`${scdKey}_scd`] = { success: 0, failed: 0, error: err.message };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
log(`${'─'.repeat(50)}\n`);
|
|
231
|
+
|
|
232
|
+
// Clean up batch files if needed
|
|
233
|
+
if (!writeToDisk && isBATCH_MODE) {
|
|
234
|
+
const writeDir = eventData?.getWriteDir?.() || userProfilesData?.getWriteDir?.();
|
|
235
|
+
if (writeDir) {
|
|
236
|
+
const configName = context.config.name;
|
|
237
|
+
const listDir = await ls(writeDir);
|
|
238
|
+
// @ts-ignore
|
|
239
|
+
const files = listDir.filter(f => {
|
|
240
|
+
if (configName && !f.includes(configName)) return false;
|
|
241
|
+
return f.includes('-EVENTS') ||
|
|
242
|
+
f.includes('-USERS') ||
|
|
243
|
+
f.includes('-ADSPEND') ||
|
|
244
|
+
f.includes('-GROUPS') ||
|
|
245
|
+
f.includes('-GROUP-EVENTS') ||
|
|
246
|
+
f.includes('-SCD') ||
|
|
247
|
+
f.includes('-MIRROR') ||
|
|
248
|
+
f.includes('-LOOKUP');
|
|
249
|
+
});
|
|
250
|
+
for (const file of files) {
|
|
251
|
+
await rm(file);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return importResults;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Logging function that respects verbose config
|
|
261
|
+
* @param {string} message - Message to log
|
|
262
|
+
*/
|
|
263
|
+
let _verbose = true;
|
|
264
|
+
function log(message) {
|
|
265
|
+
if (_verbose) console.log(message);
|
|
266
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Loop Orchestrator module
|
|
3
|
+
* Manages user generation and event creation workflow
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types').Context} Context */
|
|
7
|
+
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
import pLimit from 'p-limit';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import * as u from "../utils/utils.js";
|
|
12
|
+
import * as t from 'ak-tools';
|
|
13
|
+
import { makeEvent } from "../generators/events.js";
|
|
14
|
+
import { makeFunnel } from "../generators/funnels.js";
|
|
15
|
+
import { makeUserProfile } from "../generators/profiles.js";
|
|
16
|
+
import { makeSCD } from "../generators/scd.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Main user generation loop that creates users, their profiles, events, and SCDs
|
|
20
|
+
* @param {Context} context - Context object containing config, defaults, storage, etc.
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
export async function userLoop(context) {
|
|
24
|
+
const { config, storage, defaults } = context;
|
|
25
|
+
const chance = u.getChance();
|
|
26
|
+
const concurrency = config?.concurrency ?? 1;
|
|
27
|
+
const USER_CONN = pLimit(concurrency);
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
verbose,
|
|
31
|
+
numUsers,
|
|
32
|
+
numEvents,
|
|
33
|
+
isAnonymous,
|
|
34
|
+
hasAvatar,
|
|
35
|
+
hasAnonIds,
|
|
36
|
+
hasSessionIds,
|
|
37
|
+
hasLocation,
|
|
38
|
+
funnels,
|
|
39
|
+
userProps,
|
|
40
|
+
scdProps,
|
|
41
|
+
numDays,
|
|
42
|
+
percentUsersBornInDataset = 15,
|
|
43
|
+
strictEventCount = false,
|
|
44
|
+
bornRecentBias = 0.3, // 0 = uniform distribution, 1 = heavily biased toward recent births
|
|
45
|
+
} = config;
|
|
46
|
+
|
|
47
|
+
const { eventData, userProfilesData, scdTableData } = storage;
|
|
48
|
+
const avgEvPerUser = numEvents / numUsers;
|
|
49
|
+
const startTime = Date.now();
|
|
50
|
+
|
|
51
|
+
// Create batches for parallel processing
|
|
52
|
+
const batchSize = Math.max(1, Math.ceil(numUsers / concurrency));
|
|
53
|
+
const userPromises = [];
|
|
54
|
+
|
|
55
|
+
// Track if we've already logged the strict event count message
|
|
56
|
+
let hasLoggedStrictCountReached = false;
|
|
57
|
+
|
|
58
|
+
// Handle graceful shutdown on SIGINT (Ctrl+C)
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
const onSigint = () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
USER_CONN.clearQueue();
|
|
63
|
+
if (verbose) console.log(`\n\nStopping generation (Ctrl+C)...\n`);
|
|
64
|
+
};
|
|
65
|
+
process.on('SIGINT', onSigint);
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < numUsers; i++) {
|
|
68
|
+
const userPromise = USER_CONN(async () => {
|
|
69
|
+
// Bail out if cancelled
|
|
70
|
+
if (cancelled) return;
|
|
71
|
+
|
|
72
|
+
// Bail out early if strictEventCount is enabled and we've hit numEvents
|
|
73
|
+
if (strictEventCount && context.getEventCount() >= numEvents) {
|
|
74
|
+
if (verbose && !hasLoggedStrictCountReached) {
|
|
75
|
+
console.log(`\n\u2713 Reached target of ${numEvents.toLocaleString()} events with strict event count enabled. Stopping user generation.`);
|
|
76
|
+
hasLoggedStrictCountReached = true;
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
context.incrementUserCount();
|
|
82
|
+
const eps = Math.floor(context.getEventCount() / ((Date.now() - startTime) / 1000));
|
|
83
|
+
const memUsed = u.bytesHuman(process.memoryUsage().heapUsed);
|
|
84
|
+
const duration = u.formatDuration(Date.now() - startTime);
|
|
85
|
+
|
|
86
|
+
if (verbose) {
|
|
87
|
+
u.progress([
|
|
88
|
+
["users", context.getUserCount()],
|
|
89
|
+
["events", context.getEventCount()],
|
|
90
|
+
["eps", eps],
|
|
91
|
+
["mem", memUsed],
|
|
92
|
+
["time", duration]
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const userId = chance.guid();
|
|
97
|
+
const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds });
|
|
98
|
+
const { distinct_id, created } = user;
|
|
99
|
+
const userIsBornInDataset = chance.bool({ likelihood: percentUsersBornInDataset });
|
|
100
|
+
let numEventsPreformed = 0;
|
|
101
|
+
|
|
102
|
+
if (!userIsBornInDataset) delete user.created;
|
|
103
|
+
|
|
104
|
+
// Calculate time adjustments
|
|
105
|
+
const daysShift = context.getDaysShift();
|
|
106
|
+
|
|
107
|
+
// Apply recency bias to birth dates for users born in dataset
|
|
108
|
+
// bornRecentBias: 0 = uniform distribution, 1 = heavily biased toward recent
|
|
109
|
+
let adjustedCreated;
|
|
110
|
+
if (userIsBornInDataset) {
|
|
111
|
+
let biasedCreated = dayjs(created).subtract(daysShift, 'd');
|
|
112
|
+
|
|
113
|
+
if (bornRecentBias !== 0) {
|
|
114
|
+
// Calculate how far into the dataset this user was born (0 = start, 1 = end/recent)
|
|
115
|
+
const datasetStart = dayjs.unix(global.FIXED_BEGIN);
|
|
116
|
+
const datasetEnd = dayjs.unix(context.FIXED_NOW);
|
|
117
|
+
const totalDuration = datasetEnd.diff(datasetStart);
|
|
118
|
+
// Clamp userPosition to [0, 1] to handle edge cases from rounding in time calculations
|
|
119
|
+
const userPosition = Math.max(0, Math.min(1, biasedCreated.diff(datasetStart) / totalDuration));
|
|
120
|
+
|
|
121
|
+
let biasedPosition;
|
|
122
|
+
if (bornRecentBias > 0) {
|
|
123
|
+
// Positive bias: exponent < 1 shifts distribution toward 1 (recent)
|
|
124
|
+
const exponent = 1 - (bornRecentBias * 0.7); // 0.3 bias -> 0.79 exponent (gentle nudge)
|
|
125
|
+
biasedPosition = Math.pow(userPosition, exponent);
|
|
126
|
+
} else {
|
|
127
|
+
// Negative bias: mirror the power function to shift toward 0 (early)
|
|
128
|
+
// -0.3 bias -> 0.79 exponent applied to (1 - position), then mirrored back
|
|
129
|
+
const exponent = 1 - (Math.abs(bornRecentBias) * 0.7);
|
|
130
|
+
biasedPosition = 1 - Math.pow(1 - userPosition, exponent);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Convert back to timestamp
|
|
134
|
+
biasedCreated = datasetStart.add(biasedPosition * totalDuration, 'millisecond');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
adjustedCreated = biasedCreated;
|
|
138
|
+
// Update user.created to match biased timestamp for profile consistency
|
|
139
|
+
user.created = adjustedCreated.toISOString();
|
|
140
|
+
} else {
|
|
141
|
+
adjustedCreated = dayjs.unix(global.FIXED_BEGIN);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (hasLocation) {
|
|
145
|
+
const location = u.pickRandom(u.choose(defaults.locationsUsers));
|
|
146
|
+
for (const key in location) {
|
|
147
|
+
user[key] = location[key];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Profile creation
|
|
152
|
+
const profile = await makeUserProfile(context, userProps, user);
|
|
153
|
+
|
|
154
|
+
// Call user hook after profile creation
|
|
155
|
+
if (config.hook) {
|
|
156
|
+
await config.hook(profile, "user", {
|
|
157
|
+
user,
|
|
158
|
+
config,
|
|
159
|
+
userIsBornInDataset
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// SCD creation
|
|
164
|
+
// @ts-ignore
|
|
165
|
+
const scdUserTables = t.objFilter(scdProps, (scd) => scd.type === 'user' || !scd.type);
|
|
166
|
+
const scdTableKeys = Object.keys(scdUserTables);
|
|
167
|
+
|
|
168
|
+
const userSCD = {};
|
|
169
|
+
for (const [index, key] of scdTableKeys.entries()) {
|
|
170
|
+
const { max = 10 } = scdProps[key];
|
|
171
|
+
const mutations = chance.integer({ min: 1, max });
|
|
172
|
+
let changes = await makeSCD(context, scdProps[key], key, distinct_id, mutations, created);
|
|
173
|
+
userSCD[key] = changes;
|
|
174
|
+
|
|
175
|
+
const hookResult = await config.hook(changes, "scd-pre", {
|
|
176
|
+
profile,
|
|
177
|
+
type: 'user',
|
|
178
|
+
scd: { [key]: scdProps[key] },
|
|
179
|
+
config,
|
|
180
|
+
allSCDs: userSCD
|
|
181
|
+
});
|
|
182
|
+
if (Array.isArray(hookResult)) {
|
|
183
|
+
changes = hookResult;
|
|
184
|
+
userSCD[key] = changes;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let numEventsThisUserWillPreform = Math.floor(chance.normal({
|
|
189
|
+
mean: avgEvPerUser,
|
|
190
|
+
dev: avgEvPerUser / u.integer(u.integer(2, 5), u.integer(2, 7))
|
|
191
|
+
}) * 0.714159265359);
|
|
192
|
+
|
|
193
|
+
// Power users and low-activity users logic
|
|
194
|
+
chance.bool({ likelihood: 20 }) ? numEventsThisUserWillPreform *= 5 : null;
|
|
195
|
+
chance.bool({ likelihood: 15 }) ? numEventsThisUserWillPreform *= 0.333 : null;
|
|
196
|
+
numEventsThisUserWillPreform = Math.round(numEventsThisUserWillPreform);
|
|
197
|
+
|
|
198
|
+
let userFirstEventTime;
|
|
199
|
+
|
|
200
|
+
const firstFunnels = funnels.filter((f) => f.isFirstFunnel)
|
|
201
|
+
.filter((f) => !f.conditions || matchConditions(profile, f.conditions))
|
|
202
|
+
.reduce(weighFunnels, []);
|
|
203
|
+
const usageFunnels = funnels.filter((f) => !f.isFirstFunnel)
|
|
204
|
+
.filter((f) => !f.conditions || matchConditions(profile, f.conditions))
|
|
205
|
+
.reduce(weighFunnels, []);
|
|
206
|
+
|
|
207
|
+
const secondsInDay = 86400;
|
|
208
|
+
const noise = () => chance.integer({ min: 0, max: secondsInDay });
|
|
209
|
+
let usersEvents = [];
|
|
210
|
+
let userConverted = true;
|
|
211
|
+
|
|
212
|
+
// Pre-compute weighted events array for standalone event selection
|
|
213
|
+
const weightedEvents = config.events.reduce((acc, event) => {
|
|
214
|
+
const w = Math.max(1, Math.min(Math.floor(event.weight) || 1, 10));
|
|
215
|
+
for (let i = 0; i < w; i++) acc.push(event);
|
|
216
|
+
return acc;
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
// Build churn event lookup: { eventName: returnLikelihood }
|
|
220
|
+
const churnEvents = new Map();
|
|
221
|
+
for (const ev of config.events) {
|
|
222
|
+
if (ev.isChurnEvent) {
|
|
223
|
+
churnEvents.set(ev.event, ev.returnLikelihood ?? 0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// PATH FOR USERS BORN IN DATASET AND PERFORMING FIRST FUNNEL
|
|
228
|
+
if (firstFunnels.length && userIsBornInDataset) {
|
|
229
|
+
const firstFunnel = chance.pickone(firstFunnels, user);
|
|
230
|
+
const firstTime = adjustedCreated.subtract(noise(), 'seconds').unix();
|
|
231
|
+
const [data, converted] = await makeFunnel(context, firstFunnel, user, firstTime, profile, userSCD);
|
|
232
|
+
userConverted = converted;
|
|
233
|
+
|
|
234
|
+
const timeShift = context.getTimeShift();
|
|
235
|
+
userFirstEventTime = dayjs(data[0].time).subtract(timeShift, 'seconds').unix();
|
|
236
|
+
numEventsPreformed += data.length;
|
|
237
|
+
usersEvents = usersEvents.concat(data);
|
|
238
|
+
} else {
|
|
239
|
+
userFirstEventTime = adjustedCreated.subtract(noise(), 'seconds').unix();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ALL SUBSEQUENT EVENTS (funnels for converted users, standalone for all)
|
|
243
|
+
let userChurned = false;
|
|
244
|
+
while (numEventsPreformed < numEventsThisUserWillPreform && !cancelled) {
|
|
245
|
+
let newEvents;
|
|
246
|
+
if (usageFunnels.length && userConverted) {
|
|
247
|
+
const currentFunnel = chance.pickone(usageFunnels);
|
|
248
|
+
const [data, converted] = await makeFunnel(context, currentFunnel, user, userFirstEventTime, profile, userSCD);
|
|
249
|
+
numEventsPreformed += data.length;
|
|
250
|
+
newEvents = data;
|
|
251
|
+
} else {
|
|
252
|
+
const data = await makeEvent(context, distinct_id, userFirstEventTime, u.pick(weightedEvents), user.anonymousIds, user.sessionIds, {}, config.groupKeys, true);
|
|
253
|
+
numEventsPreformed++;
|
|
254
|
+
newEvents = [data];
|
|
255
|
+
}
|
|
256
|
+
usersEvents = usersEvents.concat(newEvents);
|
|
257
|
+
|
|
258
|
+
// Check for churn events — if user churned, they may stop generating
|
|
259
|
+
if (churnEvents.size > 0) {
|
|
260
|
+
const eventsToCheck = Array.isArray(newEvents[0]) ? newEvents.flat() : newEvents;
|
|
261
|
+
for (const ev of eventsToCheck) {
|
|
262
|
+
if (ev.event && churnEvents.has(ev.event)) {
|
|
263
|
+
const returnLikelihood = churnEvents.get(ev.event);
|
|
264
|
+
const userReturns = returnLikelihood > 0 && chance.bool({ likelihood: returnLikelihood * 100 });
|
|
265
|
+
if (!userReturns) {
|
|
266
|
+
userChurned = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (userChurned) break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Remove events flagged as future timestamps (before dungeon hooks see them)
|
|
276
|
+
usersEvents = usersEvents.filter(e => !e._drop);
|
|
277
|
+
|
|
278
|
+
// Hook for processing all user events
|
|
279
|
+
if (config.hook) {
|
|
280
|
+
const newEvents = await config.hook(usersEvents, "everything", {
|
|
281
|
+
profile,
|
|
282
|
+
scd: userSCD,
|
|
283
|
+
config,
|
|
284
|
+
userIsBornInDataset
|
|
285
|
+
});
|
|
286
|
+
if (Array.isArray(newEvents)) usersEvents = newEvents;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Store all user data
|
|
290
|
+
await userProfilesData.hookPush(profile);
|
|
291
|
+
|
|
292
|
+
if (Object.keys(userSCD).length) {
|
|
293
|
+
for (const [key, changesArray] of Object.entries(userSCD)) {
|
|
294
|
+
for (const changes of changesArray) {
|
|
295
|
+
try {
|
|
296
|
+
const target = scdTableData.filter(arr => arr.scdKey === key).pop();
|
|
297
|
+
await target.hookPush(changes, { profile, type: 'user' });
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
// This is probably a test
|
|
301
|
+
const target = scdTableData[0];
|
|
302
|
+
await target.hookPush(changes, { profile, type: 'user' });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await eventData.hookPush(usersEvents, { profile });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
userPromises.push(userPromise);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Wait for all users to complete
|
|
315
|
+
await Promise.all(userPromises);
|
|
316
|
+
|
|
317
|
+
// Clean up SIGINT handler
|
|
318
|
+
process.removeListener('SIGINT', onSigint);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
export function weighFunnels(acc, funnel) {
|
|
323
|
+
const weight = funnel?.weight || 1;
|
|
324
|
+
for (let i = 0; i < weight; i++) {
|
|
325
|
+
acc.push(funnel);
|
|
326
|
+
}
|
|
327
|
+
return acc;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function matchConditions(profile, conditions) {
|
|
331
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
332
|
+
if (profile[key] !== value) return false;
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|