@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.
Files changed (66) hide show
  1. package/README.md +518 -0
  2. package/dungeons/array-of-object-lookup-schema.json +327 -0
  3. package/dungeons/array-of-object-lookup.js +220 -0
  4. package/dungeons/ecommerce-schema.json +462 -0
  5. package/dungeons/ecommerce.js +447 -0
  6. package/dungeons/education-schema.json +2409 -0
  7. package/dungeons/education.js +768 -0
  8. package/dungeons/fintech-schema.json +14034 -0
  9. package/dungeons/fintech.js +696 -0
  10. package/dungeons/foobar-schema.json +403 -0
  11. package/dungeons/foobar.js +296 -0
  12. package/dungeons/food-delivery-schema.json +192 -0
  13. package/dungeons/food-delivery.js +602 -0
  14. package/dungeons/food-schema.json +1152 -0
  15. package/dungeons/food.js +754 -0
  16. package/dungeons/gaming-schema.json +1270 -0
  17. package/dungeons/gaming.js +508 -0
  18. package/dungeons/insurance-application-schema.json +204 -0
  19. package/dungeons/insurance-application.js +605 -0
  20. package/dungeons/media-schema.json +906 -0
  21. package/dungeons/media.js +790 -0
  22. package/dungeons/retention-cadence-schema.json +78 -0
  23. package/dungeons/retention-cadence.js +244 -0
  24. package/dungeons/rpg-schema.json +4526 -0
  25. package/dungeons/rpg.js +919 -0
  26. package/dungeons/sanity-schema.json +255 -0
  27. package/dungeons/sanity.js +152 -0
  28. package/dungeons/sass-schema.json +1291 -0
  29. package/dungeons/sass.js +795 -0
  30. package/dungeons/scd-schema.json +919 -0
  31. package/dungeons/scd.js +277 -0
  32. package/dungeons/simple-schema.json +608 -0
  33. package/dungeons/simple.js +285 -0
  34. package/dungeons/simplest-schema.json +1418 -0
  35. package/dungeons/simplest.js +392 -0
  36. package/dungeons/social-schema.json +1118 -0
  37. package/dungeons/social.js +686 -0
  38. package/dungeons/text-generation-schema.json +3096 -0
  39. package/dungeons/text-generation.js +812 -0
  40. package/index.js +567 -0
  41. package/lib/core/config-validator.js +395 -0
  42. package/lib/core/context.js +204 -0
  43. package/lib/core/dungeon-loader.js +337 -0
  44. package/lib/core/storage.js +379 -0
  45. package/lib/generators/adspend.js +132 -0
  46. package/lib/generators/events.js +271 -0
  47. package/lib/generators/funnels.js +407 -0
  48. package/lib/generators/mirror.js +167 -0
  49. package/lib/generators/product-lookup.js +262 -0
  50. package/lib/generators/product-names.js +195 -0
  51. package/lib/generators/profiles.js +93 -0
  52. package/lib/generators/scd.js +124 -0
  53. package/lib/generators/text.js +1192 -0
  54. package/lib/orchestrators/mixpanel-sender.js +266 -0
  55. package/lib/orchestrators/user-loop.js +335 -0
  56. package/lib/templates/abbreviated.d.ts +169 -0
  57. package/lib/templates/defaults.js +1405 -0
  58. package/lib/templates/phrases.js +2526 -0
  59. package/lib/templates/schema.d.ts +173 -0
  60. package/lib/templates/soup-presets.js +188 -0
  61. package/lib/utils/function-registry.js +302 -0
  62. package/lib/utils/json-evaluator.js +172 -0
  63. package/lib/utils/logger.js +34 -0
  64. package/lib/utils/utils.js +1490 -0
  65. package/package.json +89 -0
  66. 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
+ }