@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,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dungeon loader: resolves dungeon input from multiple formats
|
|
3
|
+
* Supports: config objects, file paths (.js/.mjs/.json), arrays of paths, and raw JS text
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
import Chance from 'chance';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect what kind of input was passed and normalize it
|
|
17
|
+
* @param {any} input - The raw input to DUNGEON_MASTER
|
|
18
|
+
* @returns {{ type: 'object' | 'file' | 'files' | 'text', value: any }}
|
|
19
|
+
*/
|
|
20
|
+
export function detectInputType(input) {
|
|
21
|
+
if (input === null || input === undefined) {
|
|
22
|
+
throw new Error('dungeon-master: input is required. pass a config object, file path, array of paths, or javascript string.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Array of file paths
|
|
26
|
+
if (Array.isArray(input)) {
|
|
27
|
+
if (input.length === 0) {
|
|
28
|
+
throw new Error('dungeon-master: empty array. pass at least one dungeon file path.');
|
|
29
|
+
}
|
|
30
|
+
for (const item of input) {
|
|
31
|
+
if (typeof item !== 'string') {
|
|
32
|
+
throw new Error(`dungeon-master: array items must be file path strings. got ${typeof item}.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { type: 'files', value: input };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Plain config object
|
|
39
|
+
if (typeof input === 'object') {
|
|
40
|
+
return { type: 'object', value: input };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// String: file path or raw JS text
|
|
44
|
+
if (typeof input === 'string') {
|
|
45
|
+
const trimmed = input.trim();
|
|
46
|
+
|
|
47
|
+
// Check if it looks like a file path (short, has file extension, no newlines)
|
|
48
|
+
if (!trimmed.includes('\n') && looksLikeFilePath(trimmed)) {
|
|
49
|
+
return { type: 'file', value: trimmed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Otherwise treat as raw JavaScript text
|
|
53
|
+
return { type: 'text', value: trimmed };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(`dungeon-master: unsupported input type "${typeof input}". expected object, string, or array.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a string looks like a file path vs JavaScript code
|
|
61
|
+
* @param {string} str
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
function looksLikeFilePath(str) {
|
|
65
|
+
const ext = path.extname(str);
|
|
66
|
+
if (['.js', '.mjs', '.cjs', '.json'].includes(ext)) return true;
|
|
67
|
+
// Could be a path without extension - check if file exists
|
|
68
|
+
if (!str.includes('{') && !str.includes('(') && !str.includes('=')) {
|
|
69
|
+
try {
|
|
70
|
+
return existsSync(str) || existsSync(path.resolve(str));
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load a dungeon config from a file path (.js, .mjs, or .json)
|
|
80
|
+
* @param {string} filePath - Path to the dungeon file
|
|
81
|
+
* @returns {Promise<import('../../types').Dungeon>}
|
|
82
|
+
*/
|
|
83
|
+
export async function loadFromFile(filePath) {
|
|
84
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
85
|
+
? filePath
|
|
86
|
+
: path.resolve(process.cwd(), filePath);
|
|
87
|
+
|
|
88
|
+
if (!existsSync(absolutePath)) {
|
|
89
|
+
throw new Error(`dungeon-master: file not found: ${absolutePath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
93
|
+
|
|
94
|
+
if (ext === '.json') {
|
|
95
|
+
return loadFromJSONFile(absolutePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Dynamic import for .js/.mjs files
|
|
99
|
+
const module = await import(`file://${absolutePath}`);
|
|
100
|
+
const config = module.default;
|
|
101
|
+
|
|
102
|
+
if (!config || typeof config !== 'object') {
|
|
103
|
+
throw new Error(`dungeon-master: ${path.basename(absolutePath)} must have a default export that is a config object.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
validateDungeonShape(config);
|
|
107
|
+
return config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load a dungeon config from a JSON file (UI schema format)
|
|
112
|
+
* JSON dungeons use { schema: {...}, hooks: "function string", version: "4.0" }
|
|
113
|
+
* @param {string} jsonPath - Path to the JSON file
|
|
114
|
+
* @returns {Promise<import('../../types').Dungeon>}
|
|
115
|
+
*/
|
|
116
|
+
async function loadFromJSONFile(jsonPath) {
|
|
117
|
+
const raw = readFileSync(jsonPath, 'utf-8');
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
return parseJSONDungeon(parsed);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a JSON dungeon object (the UI schema format) into a runnable config
|
|
124
|
+
* Handles { schema, hooks, ... } wrapper format and plain objects
|
|
125
|
+
* @param {object} json - The parsed JSON object
|
|
126
|
+
* @returns {import('../../types').Dungeon}
|
|
127
|
+
*/
|
|
128
|
+
export function parseJSONDungeon(json) {
|
|
129
|
+
// Support both wrapped format { schema: {...}, hooks: "..." } and plain config
|
|
130
|
+
const schema = json.schema || json;
|
|
131
|
+
const hooksString = json.hooks || null;
|
|
132
|
+
|
|
133
|
+
// Reconstruct the config from the JSON schema
|
|
134
|
+
const config = reviveJSONConfig(schema);
|
|
135
|
+
|
|
136
|
+
// Attach hook if present
|
|
137
|
+
if (hooksString && typeof hooksString === 'string') {
|
|
138
|
+
config.hook = hooksString; // config-validator.js will eval string hooks
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
validateDungeonShape(config);
|
|
142
|
+
return config;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Revive JSON config by converting function-call objects back to functions
|
|
147
|
+
* JSON dungeons store functions as { functionName: "...", body: "...", args: [...] }
|
|
148
|
+
* @param {any} value
|
|
149
|
+
* @returns {any}
|
|
150
|
+
*/
|
|
151
|
+
function reviveJSONConfig(value) {
|
|
152
|
+
if (value === null || value === undefined) return value;
|
|
153
|
+
|
|
154
|
+
// Primitives
|
|
155
|
+
if (typeof value !== 'object') return value;
|
|
156
|
+
|
|
157
|
+
// Function-call objects → actual functions
|
|
158
|
+
if (value.functionName) {
|
|
159
|
+
return reviveFunctionObject(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Arrays
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map(item => reviveJSONConfig(item));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Objects
|
|
168
|
+
const result = {};
|
|
169
|
+
for (const [key, val] of Object.entries(value)) {
|
|
170
|
+
result[key] = reviveJSONConfig(val);
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convert a function-call object { functionName, body, args } back to a function
|
|
177
|
+
* JSON dungeons store functions as { functionName: "arrow", body: "function() {...}" }
|
|
178
|
+
* or { functionName: "chance.profession", args: [] }
|
|
179
|
+
*
|
|
180
|
+
* Note: many JSON-serialized function bodies reference closure variables (items,
|
|
181
|
+
* mostChosenIndex, etc.) that don't exist at revival time. These will fail to eval
|
|
182
|
+
* and fall back to null, which is handled gracefully by the config validator.
|
|
183
|
+
*
|
|
184
|
+
* @param {{ functionName: string, body?: string, args?: any[] }} obj
|
|
185
|
+
* @returns {Function|any[]|null}
|
|
186
|
+
*/
|
|
187
|
+
function reviveFunctionObject(obj) {
|
|
188
|
+
const { functionName, body, args = [] } = obj;
|
|
189
|
+
|
|
190
|
+
if (body) {
|
|
191
|
+
// Skip native code placeholders (e.g., "function () { [native code] }")
|
|
192
|
+
if (body.includes('[native code]')) return null;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Create a function factory that provides common dungeon dependencies in scope.
|
|
196
|
+
// JSON-serialized function bodies frequently reference `chance` as a free variable.
|
|
197
|
+
// eslint-disable-next-line no-new-func
|
|
198
|
+
const factory = new Function('chance', `return (${body})`);
|
|
199
|
+
const chance = new Chance();
|
|
200
|
+
const fn = factory(chance);
|
|
201
|
+
if (typeof fn === 'function') {
|
|
202
|
+
// Smoke-test: call it once to verify it doesn't reference other missing variables.
|
|
203
|
+
// If it throws (e.g., referencing `items` from a lost closure), discard it.
|
|
204
|
+
try { fn(); } catch { return null; }
|
|
205
|
+
return fn;
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Function body can't be parsed - expected for some JSON revival edge cases
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If we can't revive it, return the args as a static array (or null)
|
|
213
|
+
return args.length > 0 ? args : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Load a dungeon from raw JavaScript text
|
|
218
|
+
* Writes to a temp file within the package so that imports resolve correctly,
|
|
219
|
+
* dynamically imports it, then cleans up
|
|
220
|
+
* @param {string} code - Raw JavaScript source code
|
|
221
|
+
* @returns {Promise<import('../../types').Dungeon>}
|
|
222
|
+
*/
|
|
223
|
+
export async function loadFromText(code) {
|
|
224
|
+
const tmpDir = path.join(PACKAGE_ROOT, '.dungeon-tmp');
|
|
225
|
+
const tmpId = randomBytes(8).toString('hex');
|
|
226
|
+
const tmpFile = path.join(tmpDir, `dungeon-${tmpId}.mjs`);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// Ensure tmp directory exists
|
|
230
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
231
|
+
|
|
232
|
+
// Write the code to a temp file
|
|
233
|
+
writeFileSync(tmpFile, code, 'utf-8');
|
|
234
|
+
|
|
235
|
+
// Dynamic import
|
|
236
|
+
const module = await import(`file://${tmpFile}`);
|
|
237
|
+
const config = module.default;
|
|
238
|
+
|
|
239
|
+
if (!config || typeof config !== 'object') {
|
|
240
|
+
throw new Error('dungeon-master: text dungeon must export a default config object (use "export default { ... }").');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
validateDungeonShape(config);
|
|
244
|
+
return config;
|
|
245
|
+
|
|
246
|
+
} finally {
|
|
247
|
+
// Clean up temp file
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(tmpFile);
|
|
250
|
+
// Try to remove the tmp dir if empty
|
|
251
|
+
const { readdirSync, rmdirSync } = await import('fs');
|
|
252
|
+
const remaining = readdirSync(tmpDir);
|
|
253
|
+
if (remaining.length === 0) rmdirSync(tmpDir);
|
|
254
|
+
} catch {
|
|
255
|
+
// cleanup is best-effort
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Validate that a config object has the minimum shape of a dungeon
|
|
262
|
+
* This is a pre-flight check before passing to the full config validator
|
|
263
|
+
* @param {any} config
|
|
264
|
+
* @throws {Error} if the config is clearly not a valid dungeon
|
|
265
|
+
*/
|
|
266
|
+
export function validateDungeonShape(config) {
|
|
267
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
268
|
+
throw new Error('dungeon-master: config must be a plain object.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Must have at least one recognizable dungeon property
|
|
272
|
+
const dungeonKeys = [
|
|
273
|
+
'events', 'numEvents', 'numUsers', 'numDays', 'funnels',
|
|
274
|
+
'userProps', 'superProps', 'hook', 'token', 'seed',
|
|
275
|
+
'scdProps', 'groupKeys', 'lookupTables', 'mirrorProps',
|
|
276
|
+
'hasAdSpend', 'soup', 'format', 'writeToDisk'
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const hasAnyDungeonKey = dungeonKeys.some(key => key in config);
|
|
280
|
+
if (!hasAnyDungeonKey) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
'dungeon-master: config does not look like a dungeon. ' +
|
|
283
|
+
'expected at least one of: events, numEvents, numUsers, numDays, funnels, userProps, hook, etc.'
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Validate events if present
|
|
288
|
+
if (config.events !== undefined) {
|
|
289
|
+
if (!Array.isArray(config.events)) {
|
|
290
|
+
throw new Error('dungeon-master: "events" must be an array.');
|
|
291
|
+
}
|
|
292
|
+
for (let i = 0; i < config.events.length; i++) {
|
|
293
|
+
const ev = config.events[i];
|
|
294
|
+
if (typeof ev === 'string') continue; // string events are auto-converted
|
|
295
|
+
if (!ev || typeof ev !== 'object') {
|
|
296
|
+
throw new Error(`dungeon-master: events[${i}] must be an object or string.`);
|
|
297
|
+
}
|
|
298
|
+
if (!ev.event || typeof ev.event !== 'string') {
|
|
299
|
+
throw new Error(`dungeon-master: events[${i}] is missing a required "event" name string.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Validate funnels if present
|
|
305
|
+
if (config.funnels !== undefined) {
|
|
306
|
+
if (!Array.isArray(config.funnels)) {
|
|
307
|
+
throw new Error('dungeon-master: "funnels" must be an array.');
|
|
308
|
+
}
|
|
309
|
+
for (let i = 0; i < config.funnels.length; i++) {
|
|
310
|
+
const f = config.funnels[i];
|
|
311
|
+
if (!f || typeof f !== 'object') {
|
|
312
|
+
throw new Error(`dungeon-master: funnels[${i}] must be an object.`);
|
|
313
|
+
}
|
|
314
|
+
if (!f.sequence || !Array.isArray(f.sequence)) {
|
|
315
|
+
throw new Error(`dungeon-master: funnels[${i}] is missing a required "sequence" array.`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Validate hook if present
|
|
321
|
+
if (config.hook !== undefined) {
|
|
322
|
+
const hookType = typeof config.hook;
|
|
323
|
+
if (hookType !== 'function' && hookType !== 'string') {
|
|
324
|
+
throw new Error('dungeon-master: "hook" must be a function or a string containing a function.');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Validate numeric fields
|
|
329
|
+
const numericFields = ['numEvents', 'numUsers', 'numDays', 'batchSize', 'concurrency'];
|
|
330
|
+
for (const field of numericFields) {
|
|
331
|
+
if (config[field] !== undefined) {
|
|
332
|
+
if (typeof config[field] !== 'number' || config[field] < 0) {
|
|
333
|
+
throw new Error(`dungeon-master: "${field}" must be a positive number.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage module providing HookArray functionality for data transformation and batching
|
|
3
|
+
* Extracted from index.js to eliminate global dependencies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @typedef {import('../../types.js').Context} Context */
|
|
7
|
+
/** @typedef {import('../../types.js').HookedArray<any>} HookedArray */
|
|
8
|
+
/** @typedef {import('../../types.js').Storage} Storage */
|
|
9
|
+
/** @typedef {import('../../types.js').hookArrayOptions<any>} hookArrayOptions */
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import pLimit from 'p-limit';
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import * as u from "../utils/utils.js";
|
|
16
|
+
import { dataLogger as logger } from "../utils/logger.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a hooked array that transforms data on push and handles batching/disk writes
|
|
20
|
+
* @param {Array} arr - Base array to enhance
|
|
21
|
+
* @param {hookArrayOptions} opts - Configuration options
|
|
22
|
+
* @returns {Promise<HookedArray>} Enhanced array with hookPush and flush methods
|
|
23
|
+
*/
|
|
24
|
+
export async function createHookArray(arr = [], opts) {
|
|
25
|
+
const {
|
|
26
|
+
hook = a => a,
|
|
27
|
+
type = "",
|
|
28
|
+
filepath = "./defaultFile",
|
|
29
|
+
format = "csv",
|
|
30
|
+
concurrency = 1,
|
|
31
|
+
context = /** @type {Context} */ ({}),
|
|
32
|
+
...rest
|
|
33
|
+
} = opts || {};
|
|
34
|
+
|
|
35
|
+
const FILE_CONN = pLimit(concurrency);
|
|
36
|
+
const {
|
|
37
|
+
config = {},
|
|
38
|
+
runtime = {
|
|
39
|
+
operations: 0,
|
|
40
|
+
eventCount: 0,
|
|
41
|
+
userCount: 0,
|
|
42
|
+
isBatchMode: false,
|
|
43
|
+
verbose: false
|
|
44
|
+
}
|
|
45
|
+
} = context;
|
|
46
|
+
const BATCH_SIZE = config.batchSize || 1_000_000;
|
|
47
|
+
const NODE_ENV = process.env.NODE_ENV || "unknown";
|
|
48
|
+
|
|
49
|
+
let batch = 0;
|
|
50
|
+
let writeDir;
|
|
51
|
+
let isBatchMode = runtime.isBatchMode || false;
|
|
52
|
+
let isWriting = false; // Prevent concurrent writes
|
|
53
|
+
|
|
54
|
+
// Determine write directory
|
|
55
|
+
const dataFolder = path.resolve("./data");
|
|
56
|
+
if (existsSync(dataFolder)) writeDir = dataFolder;
|
|
57
|
+
else writeDir = path.resolve("./");
|
|
58
|
+
|
|
59
|
+
if (NODE_ENV?.toLowerCase()?.startsWith("prod")) {
|
|
60
|
+
writeDir = path.resolve(os.tmpdir());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof config.writeToDisk === "string" && config.writeToDisk.startsWith('gs://')) {
|
|
64
|
+
writeDir = config.writeToDisk;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getWritePath() {
|
|
68
|
+
const gzipSuffix = (config.gzip) ? '.gz' : '';
|
|
69
|
+
|
|
70
|
+
if (isBatchMode) {
|
|
71
|
+
if (writeDir?.startsWith('gs://')) return `${writeDir}/${filepath}-part-${batch.toString()}.${format}${gzipSuffix}`;
|
|
72
|
+
return path.join(writeDir, `${filepath}-part-${batch.toString()}.${format}${gzipSuffix}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
if (writeDir?.startsWith('gs://')) return `${writeDir}/${filepath}.${format}${gzipSuffix}`;
|
|
76
|
+
return path.join(writeDir, `${filepath}.${format}${gzipSuffix}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getWriteDir() {
|
|
81
|
+
return writeDir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function transformThenPush(item, meta) {
|
|
85
|
+
if (item === null || item === undefined) return false;
|
|
86
|
+
if (typeof item === 'object' && Object.keys(item).length === 0) return false;
|
|
87
|
+
|
|
88
|
+
// Skip hook for types already hooked in generators/orchestrators to prevent double-firing
|
|
89
|
+
// Types hooked upstream: "event" (events.js), "user" (user-loop.js), "scd" (user-loop.js)
|
|
90
|
+
// Types only hooked here: "mirror", "ad-spend", "group", "lookup"
|
|
91
|
+
const alreadyHooked = type === "event" || type === "user" || type === "scd";
|
|
92
|
+
|
|
93
|
+
// Performance optimization: skip hook overhead for passthrough hooks
|
|
94
|
+
// Only treat as passthrough if the function body is trivially simple (just returns its argument)
|
|
95
|
+
const hookStr = hook.toString();
|
|
96
|
+
const isPassthroughHook = hook.length === 1 || /^\s*function\s*\([^)]*\)\s*\{\s*return\s+\w+;?\s*\}\s*$/.test(hookStr) || /^\s*\(?[^)]*\)?\s*=>\s*\w+\s*$/.test(hookStr);
|
|
97
|
+
|
|
98
|
+
if (alreadyHooked || isPassthroughHook) {
|
|
99
|
+
// Fast path for passthrough hooks - no transformation needed
|
|
100
|
+
if (Array.isArray(item)) {
|
|
101
|
+
arr.push(...item);
|
|
102
|
+
} else {
|
|
103
|
+
arr.push(item);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Slow path for actual transformation hooks
|
|
107
|
+
const allMetaData = { ...rest, ...meta };
|
|
108
|
+
|
|
109
|
+
// Helper to validate events have required properties
|
|
110
|
+
// Note: event-type hooks are handled in the fast path (alreadyHooked),
|
|
111
|
+
// so this only runs for storage-only hook types (mirror, ad-spend, group, lookup)
|
|
112
|
+
const isValidEvent = (e) => {
|
|
113
|
+
if (!e || typeof e !== 'object') return false;
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(item)) {
|
|
118
|
+
for (const i of item) {
|
|
119
|
+
try {
|
|
120
|
+
const enriched = await hook(i, type, allMetaData);
|
|
121
|
+
if (Array.isArray(enriched)) {
|
|
122
|
+
enriched.forEach(e => {
|
|
123
|
+
if (isValidEvent(e)) arr.push(e);
|
|
124
|
+
});
|
|
125
|
+
} else if (isValidEvent(enriched)) {
|
|
126
|
+
arr.push(enriched);
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
logger.error({ err: e }, 'Hook error during batch processing');
|
|
130
|
+
if (isValidEvent(i)) arr.push(i);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
const enriched = await hook(item, type, allMetaData);
|
|
136
|
+
if (Array.isArray(enriched)) {
|
|
137
|
+
enriched.forEach(e => {
|
|
138
|
+
if (isValidEvent(e)) arr.push(e);
|
|
139
|
+
});
|
|
140
|
+
} else if (isValidEvent(enriched)) {
|
|
141
|
+
arr.push(enriched);
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
logger.error({ err: e }, 'Hook error during single item processing');
|
|
145
|
+
if (isValidEvent(item)) arr.push(item);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check batch size and handle writes synchronously to prevent race conditions
|
|
151
|
+
if (arr.length > BATCH_SIZE && !isWriting) {
|
|
152
|
+
isWriting = true; // Lock to prevent concurrent writes
|
|
153
|
+
isBatchMode = true;
|
|
154
|
+
runtime.isBatchMode = true; // Update runtime state
|
|
155
|
+
batch++;
|
|
156
|
+
const writePath = getWritePath();
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Create a copy of the data to write
|
|
160
|
+
const dataToWrite = [...arr];
|
|
161
|
+
// Clear the array immediately to prevent race conditions
|
|
162
|
+
arr.length = 0;
|
|
163
|
+
|
|
164
|
+
// Write to disk/cloud - always blocking to prevent OOM
|
|
165
|
+
const writeResult = await FILE_CONN(() => writeToDisk(dataToWrite, { writePath }));
|
|
166
|
+
return writeResult;
|
|
167
|
+
} finally {
|
|
168
|
+
isWriting = false; // Release the lock
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
return Promise.resolve(false);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeToDisk(data, options) {
|
|
176
|
+
const { writePath } = options;
|
|
177
|
+
let writeResult;
|
|
178
|
+
|
|
179
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
180
|
+
if (config.verbose && isDev) {
|
|
181
|
+
console.log(`\n\twriting ${writePath}\n`);
|
|
182
|
+
} else if (config.verbose) {
|
|
183
|
+
logger.info({ path: writePath }, `Writing ${writePath}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const streamOptions = {
|
|
187
|
+
gzip: config.gzip || false
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
switch (format) {
|
|
191
|
+
case "csv":
|
|
192
|
+
writeResult = await u.streamCSV(writePath, data, streamOptions);
|
|
193
|
+
break;
|
|
194
|
+
case "json":
|
|
195
|
+
writeResult = await u.streamJSON(writePath, data, streamOptions);
|
|
196
|
+
break;
|
|
197
|
+
case "parquet":
|
|
198
|
+
writeResult = await u.streamParquet(writePath, data, streamOptions);
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
throw new Error(`format ${format} is not supported`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Array clearing now handled in transformThenPush to ensure proper timing
|
|
205
|
+
return writeResult;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function flush() {
|
|
209
|
+
if (arr.length > 0) {
|
|
210
|
+
// Wait for any ongoing writes to complete
|
|
211
|
+
while (isWriting) {
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
isWriting = true;
|
|
216
|
+
try {
|
|
217
|
+
batch++;
|
|
218
|
+
const writePath = getWritePath();
|
|
219
|
+
const dataToWrite = [...arr];
|
|
220
|
+
arr.length = 0; // Clear array after copying data
|
|
221
|
+
await FILE_CONN(() => writeToDisk(dataToWrite, { writePath }));
|
|
222
|
+
} finally {
|
|
223
|
+
isWriting = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Enhance the array with our methods
|
|
229
|
+
/** @type {HookedArray} */
|
|
230
|
+
const enrichedArray = /** @type {any} */ (arr);
|
|
231
|
+
enrichedArray.hookPush = transformThenPush;
|
|
232
|
+
enrichedArray.flush = flush;
|
|
233
|
+
enrichedArray.getWriteDir = getWriteDir;
|
|
234
|
+
enrichedArray.getWritePath = getWritePath;
|
|
235
|
+
|
|
236
|
+
// Add additional properties from rest
|
|
237
|
+
for (const key in rest) {
|
|
238
|
+
enrichedArray[key.toString()] = rest[key];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return enrichedArray;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Storage manager class for initializing and managing all storage containers
|
|
246
|
+
*/
|
|
247
|
+
export class StorageManager {
|
|
248
|
+
constructor(context) {
|
|
249
|
+
this.context = context;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Initialize all storage containers for the data generation process
|
|
254
|
+
* @returns {Promise<Storage>} Storage containers object
|
|
255
|
+
*/
|
|
256
|
+
async initializeContainers() {
|
|
257
|
+
const { config } = this.context;
|
|
258
|
+
|
|
259
|
+
// Validate configuration for potential data loss scenarios
|
|
260
|
+
this.validateConfiguration(config);
|
|
261
|
+
|
|
262
|
+
/** @type {Storage} */
|
|
263
|
+
const storage = {
|
|
264
|
+
eventData: await createHookArray([], {
|
|
265
|
+
hook: config.hook,
|
|
266
|
+
type: "event",
|
|
267
|
+
filepath: `${config.name}-EVENTS`,
|
|
268
|
+
format: config.format || "csv",
|
|
269
|
+
concurrency: config.concurrency || 1,
|
|
270
|
+
context: this.context
|
|
271
|
+
}),
|
|
272
|
+
|
|
273
|
+
userProfilesData: await createHookArray([], {
|
|
274
|
+
hook: config.hook,
|
|
275
|
+
type: "user",
|
|
276
|
+
filepath: `${config.name}-USERS`,
|
|
277
|
+
format: config.format || "csv",
|
|
278
|
+
concurrency: config.concurrency || 1,
|
|
279
|
+
context: this.context
|
|
280
|
+
}),
|
|
281
|
+
|
|
282
|
+
adSpendData: await createHookArray([], {
|
|
283
|
+
hook: config.hook,
|
|
284
|
+
type: "ad-spend",
|
|
285
|
+
filepath: `${config.name}-ADSPEND`,
|
|
286
|
+
format: config.format || "csv",
|
|
287
|
+
concurrency: config.concurrency || 1,
|
|
288
|
+
context: this.context
|
|
289
|
+
}),
|
|
290
|
+
|
|
291
|
+
scdTableData: [],
|
|
292
|
+
groupProfilesData: [],
|
|
293
|
+
lookupTableData: [],
|
|
294
|
+
|
|
295
|
+
mirrorEventData: await createHookArray([], {
|
|
296
|
+
hook: config.hook,
|
|
297
|
+
type: "mirror",
|
|
298
|
+
filepath: `${config.name}-MIRROR`,
|
|
299
|
+
format: config.format || "csv",
|
|
300
|
+
concurrency: config.concurrency || 1,
|
|
301
|
+
context: this.context
|
|
302
|
+
})
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Initialize SCD tables if configured
|
|
306
|
+
if (config.scdProps && Object.keys(config.scdProps).length > 0) {
|
|
307
|
+
for (const scdKey of Object.keys(config.scdProps)) {
|
|
308
|
+
const scdConfig = config.scdProps[scdKey];
|
|
309
|
+
const scdArray = await createHookArray([], {
|
|
310
|
+
hook: config.hook,
|
|
311
|
+
type: "scd",
|
|
312
|
+
filepath: `${config.name}-${scdKey}-SCD`,
|
|
313
|
+
format: config.format || "csv",
|
|
314
|
+
concurrency: config.concurrency || 1,
|
|
315
|
+
context: this.context
|
|
316
|
+
});
|
|
317
|
+
scdArray.scdKey = scdKey;
|
|
318
|
+
// Store entity type (user or group) from config
|
|
319
|
+
const entityType = (typeof scdConfig === 'object' && scdConfig.type) ? scdConfig.type : 'user';
|
|
320
|
+
scdArray.entityType = entityType;
|
|
321
|
+
storage.scdTableData.push(scdArray);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Initialize group profile tables if configured
|
|
326
|
+
if (config.groupKeys && config.groupKeys.length > 0) {
|
|
327
|
+
for (const [groupKey] of config.groupKeys) {
|
|
328
|
+
const groupArray = await createHookArray([], {
|
|
329
|
+
hook: config.hook,
|
|
330
|
+
type: "group",
|
|
331
|
+
filepath: `${config.name}-${groupKey}-GROUPS`,
|
|
332
|
+
format: config.format || "csv",
|
|
333
|
+
concurrency: config.concurrency || 1,
|
|
334
|
+
context: this.context
|
|
335
|
+
});
|
|
336
|
+
groupArray.groupKey = groupKey;
|
|
337
|
+
storage.groupProfilesData.push(groupArray);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Initialize lookup tables if configured
|
|
342
|
+
if (config.lookupTables && config.lookupTables.length > 0) {
|
|
343
|
+
for (const lookupConfig of config.lookupTables) {
|
|
344
|
+
const lookupArray = await createHookArray([], {
|
|
345
|
+
hook: config.hook,
|
|
346
|
+
type: "lookup",
|
|
347
|
+
filepath: `${config.name}-${lookupConfig.key}-LOOKUP`,
|
|
348
|
+
format: "csv", // Always force CSV for lookup tables
|
|
349
|
+
concurrency: config.concurrency || 1,
|
|
350
|
+
context: this.context
|
|
351
|
+
});
|
|
352
|
+
lookupArray.lookupKey = lookupConfig.key;
|
|
353
|
+
storage.lookupTableData.push(lookupArray);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return storage;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Validates configuration to prevent data loss scenarios
|
|
362
|
+
* @param {Object} config - Configuration object
|
|
363
|
+
*/
|
|
364
|
+
validateConfiguration(config) {
|
|
365
|
+
// Check for potential data loss scenario: writeToDisk=false with low batchSize
|
|
366
|
+
if (config.writeToDisk === false) {
|
|
367
|
+
const batchSize = config.batchSize || 1_000_000;
|
|
368
|
+
const numEvents = config.numEvents || 0;
|
|
369
|
+
|
|
370
|
+
if (batchSize < numEvents) {
|
|
371
|
+
console.warn(
|
|
372
|
+
`⚠️ writeToDisk is false but batchSize (${batchSize.toLocaleString()}) < numEvents (${numEvents.toLocaleString()}). ` +
|
|
373
|
+
`Batch files will be written to disk temporarily to avoid OOM. ` +
|
|
374
|
+
`They will be cleaned up after Mixpanel import if a token is provided.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|