@ak--47/dungeon-master 1.4.4 → 1.4.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.4.5 — 2026-05-06
6
+
7
+ ### Added
8
+
9
+ - **Progress callback.** Callers can pass `onProgress: (update) => void` on the dungeon config to receive throttled updates during generation, import, and pipeline step transitions. Update frequency is configurable via `progressInterval` (default 500ms). The callback is fault-tolerant — bad functions are caught and disabled after 3 failures, never breaking the job. Return value includes a `progress` summary with update count, error count, and disabled flag.
10
+ - **Mixpanel import progress.** When `onProgress` is set and a Mixpanel token is provided, import progress from `mixpanel-import`'s `progressCallback` is surfaced through the same `onProgress` interface as `{ phase: "import" }` updates.
11
+ - **Full TypeScript typings** for `ProgressUpdate` (discriminated union), `ProgressSummary`, `ProgressGeneration`, `ProgressImport`, and `ProgressStep`.
12
+
5
13
  ## 1.4.4 — 2026-05-06
6
14
 
7
15
  The "GCS imports actually work now" release.
package/index.js CHANGED
@@ -157,31 +157,49 @@ async function runDungeon(config) {
157
157
 
158
158
  // Step 4: Generate ad spend data (if enabled)
159
159
  if (validatedConfig.hasAdSpend) {
160
+ context.reportProgress({ phase: "step", step: "adspend", status: "start" });
161
+ const _t4 = Date.now();
160
162
  await generateAdSpendData(context);
163
+ context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
161
164
  }
162
165
 
163
166
  if (context.config.verbose) logger.info('Starting user and event generation...');
164
167
  // Step 5: Main user and event generation
168
+ context.reportProgress({ phase: "step", step: "users", status: "start" });
169
+ const _t5 = Date.now();
165
170
  await userLoop(context);
171
+ context.reportProgress({ phase: "step", step: "users", status: "complete", duration: Date.now() - _t5 });
166
172
 
167
173
  // Step 6: Generate group profiles (if configured)
168
174
  if (validatedConfig.groupKeys && validatedConfig.groupKeys.length > 0) {
175
+ context.reportProgress({ phase: "step", step: "group-profiles", status: "start" });
176
+ const _t6 = Date.now();
169
177
  await generateGroupProfiles(context);
178
+ context.reportProgress({ phase: "step", step: "group-profiles", status: "complete", duration: Date.now() - _t6 });
170
179
  }
171
180
 
172
181
  // Step 7: Generate group SCDs (if configured)
173
182
  if (validatedConfig.scdProps && validatedConfig.groupKeys && validatedConfig.groupKeys.length > 0) {
183
+ context.reportProgress({ phase: "step", step: "group-scds", status: "start" });
184
+ const _t7 = Date.now();
174
185
  await generateGroupSCDs(context);
186
+ context.reportProgress({ phase: "step", step: "group-scds", status: "complete", duration: Date.now() - _t7 });
175
187
  }
176
188
 
177
189
  // Step 8: Generate lookup tables (if configured)
178
190
  if (validatedConfig.lookupTables && validatedConfig.lookupTables.length > 0) {
191
+ context.reportProgress({ phase: "step", step: "lookups", status: "start" });
192
+ const _t8 = Date.now();
179
193
  await generateLookupTables(context);
194
+ context.reportProgress({ phase: "step", step: "lookups", status: "complete", duration: Date.now() - _t8 });
180
195
  }
181
196
 
182
197
  // Step 9: Generate mirror datasets (if configured)
183
198
  if (validatedConfig.mirrorProps && Object.keys(validatedConfig.mirrorProps).length > 0) {
199
+ context.reportProgress({ phase: "step", step: "mirrors", status: "start" });
200
+ const _t9 = Date.now();
184
201
  await makeMirror(context);
202
+ context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
185
203
  }
186
204
 
187
205
  if (context.config.verbose) logger.info('Data generation completed successfully');
@@ -191,21 +209,23 @@ async function runDungeon(config) {
191
209
  // Flush when writeToDisk is enabled OR batch mode activated (to capture tail data)
192
210
  const shouldFlush = validatedConfig.writeToDisk || context.isBatchMode();
193
211
 
194
- // Step 10: Flush lookup tables to disk (always as CSVs)
212
+ // Step 10-11: Flush to disk
195
213
  if (shouldFlush) {
214
+ context.reportProgress({ phase: "step", step: "flush", status: "start" });
215
+ const _tFlush = Date.now();
196
216
  await flushLookupTablesToDisk(storage, validatedConfig);
197
- }
198
-
199
- // Step 11: Flush other storage containers to disk
200
- if (shouldFlush) {
201
217
  await flushStorageToDisk(storage, validatedConfig);
218
+ context.reportProgress({ phase: "step", step: "flush", status: "complete", duration: Date.now() - _tFlush });
202
219
  }
203
220
 
204
221
  // Step 12: Send to Mixpanel (if token provided)
205
222
  // Now happens AFTER disk flush so batch files are available for import
206
223
  let importResults;
207
224
  if (validatedConfig.token) {
225
+ context.reportProgress({ phase: "step", step: "import", status: "start" });
226
+ const _t12 = Date.now();
208
227
  importResults = await sendToMixpanel(context);
228
+ context.reportProgress({ phase: "step", step: "import", status: "complete", duration: Date.now() - _t12 });
209
229
  }
210
230
 
211
231
  // Step 13: Compile results
@@ -214,6 +234,8 @@ async function runDungeon(config) {
214
234
 
215
235
  const extractedData = extractStorageData(storage);
216
236
 
237
+ const progressSummary = context.getProgressSummary();
238
+
217
239
  return {
218
240
  ...extractedData,
219
241
  importResults,
@@ -221,7 +243,8 @@ async function runDungeon(config) {
221
243
  time: { start, end, delta, human },
222
244
  operations: context.getOperations(),
223
245
  eventCount: context.getStoredEventCount(),
224
- userCount: context.getUserCount()
246
+ userCount: context.getUserCount(),
247
+ ...(progressSummary.updates > 0 || progressSummary.errors > 0 ? { progress: progressSummary } : {})
225
248
  };
226
249
 
227
250
  } catch (error) {
@@ -8,6 +8,8 @@
8
8
  /** @typedef {import('../../types.js').Context} Context */
9
9
  /** @typedef {import('../../types.js').RuntimeState} RuntimeState */
10
10
  /** @typedef {import('../../types.js').Defaults} Defaults */
11
+ /** @typedef {import('../../types.js').ProgressUpdate} ProgressUpdate */
12
+ /** @typedef {import('../../types.js').ProgressSummary} ProgressSummary */
11
13
 
12
14
  import dayjs from "dayjs";
13
15
  import { campaigns, devices, locations } from '../templates/defaults.js';
@@ -78,6 +80,58 @@ function createRuntimeState() {
78
80
  };
79
81
  }
80
82
 
83
+ /**
84
+ * @param {Dungeon} config
85
+ * @returns {{ reportProgress: (update: ProgressUpdate) => void, getProgressSummary: () => ProgressSummary }}
86
+ */
87
+ function createProgressReporter(config) {
88
+ const interval = config.progressInterval ?? 500;
89
+ const verbose = config.verbose || false;
90
+ let callback = config.onProgress ?? null;
91
+ let lastFireTime = 0;
92
+ let errorCount = 0;
93
+ let totalUpdates = 0;
94
+ let disabled = false;
95
+
96
+ if (callback !== null && typeof callback !== 'function') {
97
+ if (verbose) console.warn(`[dungeon-master] onProgress is not a function (got ${typeof callback}), ignoring`);
98
+ callback = null;
99
+ }
100
+
101
+ function reportProgress(/** @type {ProgressUpdate} */ update) {
102
+ if (!callback || disabled) return;
103
+
104
+ const isThrottled = update.phase === 'generation' || update.phase === 'import';
105
+ if (isThrottled) {
106
+ const now = Date.now();
107
+ if (now - lastFireTime < interval) return;
108
+ lastFireTime = now;
109
+ }
110
+
111
+ try {
112
+ const result = /** @type {any} */ (callback(update));
113
+ totalUpdates++;
114
+ if (result && typeof result.then === 'function') {
115
+ result.then(undefined, (/** @type {any} */ err) => {
116
+ errorCount++;
117
+ if (verbose) console.warn(`[dungeon-master] onProgress async error (${errorCount}/3): ${err?.message || err}`);
118
+ if (errorCount >= 3) disabled = true;
119
+ });
120
+ }
121
+ } catch (err) {
122
+ errorCount++;
123
+ if (verbose) console.warn(`[dungeon-master] onProgress error (${errorCount}/3): ${err?.message || err}`);
124
+ if (errorCount >= 3) disabled = true;
125
+ }
126
+ }
127
+
128
+ function getProgressSummary() {
129
+ return { updates: totalUpdates, errors: errorCount, disabled };
130
+ }
131
+
132
+ return { reportProgress, getProgressSummary };
133
+ }
134
+
81
135
  /**
82
136
  * Context factory that creates a complete context object for data generation
83
137
  * @param {Dungeon} config - Validated configuration object
@@ -99,12 +153,16 @@ export function createContext(config, storage = null, timeConstants = {}) {
99
153
  runtime.verbose = config.verbose || false;
100
154
  runtime.isBatchMode = config.batchSize && config.batchSize < config.numEvents;
101
155
 
156
+ const { reportProgress, getProgressSummary } = createProgressReporter(config);
157
+
102
158
  const context = {
103
159
  config,
104
160
  storage,
105
161
  defaults,
106
162
  campaigns: campaignData,
107
163
  runtime,
164
+ reportProgress,
165
+ getProgressSummary,
108
166
 
109
167
  // Helper methods for updating state
110
168
  incrementOperations() {
@@ -63,6 +63,21 @@ export async function sendToMixpanel(context) {
63
63
  workers: 35
64
64
  };
65
65
 
66
+ const hasProgressCb = typeof config.onProgress === 'function';
67
+ function makeProgressCallback(total) {
68
+ if (!hasProgressCb) return undefined;
69
+ return (recordType, processed, requests, eps, bytesProcessed) => {
70
+ context.reportProgress({
71
+ phase: "import",
72
+ recordType,
73
+ processed,
74
+ total,
75
+ eps,
76
+ bytesProcessed
77
+ });
78
+ };
79
+ }
80
+
66
81
  log(`\n${'─'.repeat(50)}`);
67
82
  log(` Importing data to Mixpanel (${region})`);
68
83
  log(`${'─'.repeat(50)}\n`);
@@ -76,9 +91,11 @@ export async function sendToMixpanel(context) {
76
91
  const files = eventData.getWrittenFiles();
77
92
  if (files.length > 0) eventDataToImport = files;
78
93
  }
94
+ const eventTotal = Array.isArray(eventDataToImport) ? eventDataToImport.length : 0;
79
95
  const imported = await mp(creds, eventDataToImport, {
80
96
  recordType: "event",
81
97
  ...commonOpts,
98
+ progressCallback: makeProgressCallback(eventTotal),
82
99
  });
83
100
  log(` -> ${comma(imported.success)} events sent\n`);
84
101
  importResults.events = imported;
@@ -93,9 +110,11 @@ export async function sendToMixpanel(context) {
93
110
  const files = userProfilesData.getWrittenFiles();
94
111
  if (files.length > 0) userProfilesToImport = files;
95
112
  }
113
+ const userTotal = Array.isArray(userProfilesToImport) ? userProfilesToImport.length : 0;
96
114
  const imported = await mp(creds, userProfilesToImport, {
97
115
  recordType: "user",
98
116
  ...commonOpts,
117
+ progressCallback: makeProgressCallback(userTotal),
99
118
  });
100
119
  log(` -> ${comma(imported.success)} user profiles sent\n`);
101
120
  importResults.users = imported;
@@ -110,9 +129,11 @@ export async function sendToMixpanel(context) {
110
129
  const files = adSpendData.getWrittenFiles();
111
130
  if (files.length > 0) adSpendDataToImport = files;
112
131
  }
132
+ const adTotal = Array.isArray(adSpendDataToImport) ? adSpendDataToImport.length : 0;
113
133
  const imported = await mp(creds, adSpendDataToImport, {
114
134
  recordType: "event",
115
135
  ...commonOpts,
136
+ progressCallback: makeProgressCallback(adTotal),
116
137
  });
117
138
  log(` -> ${comma(imported.success)} ad spend events sent\n`);
118
139
  importResults.adSpend = imported;
@@ -131,10 +152,12 @@ export async function sendToMixpanel(context) {
131
152
  const files = groupEntity.getWrittenFiles();
132
153
  if (files.length > 0) groupProfilesToImport = files;
133
154
  }
155
+ const groupTotal = Array.isArray(groupProfilesToImport) ? groupProfilesToImport.length : 0;
134
156
  const imported = await mp({ token, groupKey }, groupProfilesToImport, {
135
157
  recordType: "group",
136
158
  ...commonOpts,
137
159
  groupKey,
160
+ progressCallback: makeProgressCallback(groupTotal),
138
161
  });
139
162
  log(` -> ${comma(imported.success)} ${groupKey} profiles sent\n`);
140
163
  importResults.groups.push(imported);
@@ -150,9 +173,11 @@ export async function sendToMixpanel(context) {
150
173
  const files = groupEventData.getWrittenFiles();
151
174
  if (files.length > 0) groupEventDataToImport = files;
152
175
  }
176
+ const groupEvTotal = Array.isArray(groupEventDataToImport) ? groupEventDataToImport.length : 0;
153
177
  const imported = await mp(creds, groupEventDataToImport, {
154
178
  recordType: "event",
155
179
  ...commonOpts,
180
+ progressCallback: makeProgressCallback(groupEvTotal),
156
181
  });
157
182
  log(` -> ${comma(imported.success)} group events sent\n`);
158
183
  importResults.groupEvents = imported;
@@ -197,6 +222,7 @@ export async function sendToMixpanel(context) {
197
222
  }
198
223
 
199
224
  try {
225
+ const scdTotal = Array.isArray(scdDataToImport) ? scdDataToImport.length : 0;
200
226
  const imported = await mp(
201
227
  {
202
228
  token,
@@ -205,7 +231,7 @@ export async function sendToMixpanel(context) {
205
231
  project: projectId
206
232
  },
207
233
  scdDataToImport,
208
- options
234
+ { ...options, progressCallback: makeProgressCallback(scdTotal) }
209
235
  );
210
236
  log(` -> ${comma(imported.success)} ${scdKey} SCD entries sent\n`);
211
237
  importResults[`${scdKey}_scd`] = imported;
@@ -104,6 +104,16 @@ export async function userLoop(context) {
104
104
  ]);
105
105
  }
106
106
 
107
+ context.reportProgress({
108
+ phase: "generation",
109
+ users: context.getUserCount(),
110
+ events: context.getEventCount(),
111
+ eps,
112
+ memory: memUsed,
113
+ elapsed: duration,
114
+ percentComplete: Math.min(100, Math.round((context.getUserCount() / numUsers) * 100))
115
+ });
116
+
107
117
  const userId = chance.guid();
108
118
  const user = u.generateUser(userId, { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix: context.FIXED_NOW, avgDevicePerUser });
109
119
  const { distinct_id, created } = user;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/types.d.ts CHANGED
@@ -119,6 +119,24 @@ export interface Dungeon {
119
119
  gzip?: boolean;
120
120
  /** If true, prints progress to stdout during generation. */
121
121
  verbose?: boolean;
122
+ /**
123
+ * Optional callback that receives periodic progress updates during generation,
124
+ * import, and pipeline step transitions. Fire-and-forget: the callback is never
125
+ * awaited. If it throws 3 times, it is silently disabled for the rest of the job.
126
+ *
127
+ * The `update` argument is a discriminated union on `phase`:
128
+ * - `"generation"` — user/event counts, EPS, memory, percent complete
129
+ * - `"import"` — record type, processed/total counts, EPS, bytes
130
+ * - `"step"` — pipeline step name with start/complete status and duration
131
+ *
132
+ * @example
133
+ * onProgress: (update) => {
134
+ * if (update.phase === 'generation') ws.send(JSON.stringify(update));
135
+ * }
136
+ */
137
+ onProgress?: (update: ProgressUpdate) => void;
138
+ /** Minimum interval (ms) between progress callback invocations. Default: 500. Only throttles `generation` and `import` phases; `step` updates always fire immediately. */
139
+ progressInterval?: number;
122
140
  /**
123
141
  * @deprecated Prefer `avgDevicePerUser`. `true` is now an alias for `avgDevicePerUser: 1`
124
142
  * (single sticky device per user, every event stamped with that `device_id`). `false`
@@ -657,6 +675,12 @@ export interface Context {
657
675
  incrementUserCount(): void;
658
676
  incrementEventCount(): void;
659
677
  isBatchMode(): boolean;
678
+
679
+ // Progress callback
680
+ /** Fire a progress update to the caller's `onProgress` callback (throttled, fault-tolerant). */
681
+ reportProgress(update: ProgressUpdate): void;
682
+ /** Return the progress callback summary (updates delivered, errors, disabled flag). */
683
+ getProgressSummary(): ProgressSummary;
660
684
  }
661
685
 
662
686
  /**
@@ -1053,8 +1077,61 @@ export type Result = {
1053
1077
  userCount?: number;
1054
1078
  groupCount?: number;
1055
1079
  avgEPS?: number;
1080
+ /** Progress callback summary. Only present when `onProgress` was provided. */
1081
+ progress?: ProgressSummary;
1056
1082
  };
1057
1083
 
1084
+ // ============= Progress Callback Types =============
1085
+
1086
+ /** Discriminator for progress update types. */
1087
+ export type ProgressPhase = "generation" | "import" | "step";
1088
+
1089
+ /** Progress update emitted during user/event generation (throttled to `progressInterval`). */
1090
+ export interface ProgressGeneration {
1091
+ phase: "generation";
1092
+ users: number;
1093
+ events: number;
1094
+ eps: number;
1095
+ memory: string;
1096
+ elapsed: string;
1097
+ percentComplete: number;
1098
+ }
1099
+
1100
+ /** Progress update emitted during Mixpanel import (throttled to `progressInterval`). */
1101
+ export interface ProgressImport {
1102
+ phase: "import";
1103
+ recordType: string;
1104
+ processed: number;
1105
+ total: number;
1106
+ eps: string;
1107
+ bytesProcessed: number;
1108
+ }
1109
+
1110
+ /** Progress update emitted at pipeline step boundaries (not throttled). */
1111
+ export interface ProgressStep {
1112
+ phase: "step";
1113
+ step: string;
1114
+ status: "start" | "complete";
1115
+ /** Milliseconds elapsed (only present on `status: "complete"`). */
1116
+ duration?: number;
1117
+ }
1118
+
1119
+ /** Discriminated union of all progress update types. Discriminate on the `phase` field. */
1120
+ export type ProgressUpdate = ProgressGeneration | ProgressImport | ProgressStep;
1121
+
1122
+ /** Convenience type for the `onProgress` callback signature. */
1123
+ export type ProgressCallback = (update: ProgressUpdate) => void;
1124
+
1125
+ /** Summary of progress callback activity, included in the job result. */
1126
+ export interface ProgressSummary {
1127
+ /** Total number of updates successfully delivered to the callback. */
1128
+ updates: number;
1129
+ /** Number of times the callback threw (0-3; disabled after 3). */
1130
+ errors: number;
1131
+ /** True if the callback was disabled due to repeated failures. */
1132
+ disabled: boolean;
1133
+ }
1134
+
1058
1135
  // ============= Advanced Feature Types =============
1059
1136
 
1060
1137
  /**