@ak--47/dungeon-master 1.4.3 → 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/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) {
@@ -283,7 +283,7 @@ export function validateDungeonConfig(config) {
283
283
  hasAnonIds = false,
284
284
  hasSessionIds = false,
285
285
  sessionTimeout = 30,
286
- format = "csv",
286
+ format,
287
287
  token = null,
288
288
  region = "US",
289
289
  writeToDisk = false,
@@ -592,7 +592,7 @@ export function validateDungeonConfig(config) {
592
592
  avgDevicePerUser,
593
593
  hasSessionIds,
594
594
  sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
595
- format,
595
+ format: format || (typeof writeToDisk === 'string' && writeToDisk.startsWith('gs://') ? 'json' : 'csv'),
596
596
  token,
597
597
  region,
598
598
  writeToDisk,
@@ -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;
@@ -121,7 +142,8 @@ export async function sendToMixpanel(context) {
121
142
  // Import group profiles
122
143
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
123
144
  for (const groupEntity of groupProfilesData) {
124
- if (!groupEntity || groupEntity.length === 0) continue;
145
+ if (!groupEntity) continue;
146
+ if (groupEntity.length === 0 && !isBATCH_MODE) continue;
125
147
  const groupKey = groupEntity?.groupKey;
126
148
  log(` Group Profiles (${groupKey})`);
127
149
  let groupProfilesToImport = u.deepClone(groupEntity);
@@ -130,10 +152,12 @@ export async function sendToMixpanel(context) {
130
152
  const files = groupEntity.getWrittenFiles();
131
153
  if (files.length > 0) groupProfilesToImport = files;
132
154
  }
155
+ const groupTotal = Array.isArray(groupProfilesToImport) ? groupProfilesToImport.length : 0;
133
156
  const imported = await mp({ token, groupKey }, groupProfilesToImport, {
134
157
  recordType: "group",
135
158
  ...commonOpts,
136
159
  groupKey,
160
+ progressCallback: makeProgressCallback(groupTotal),
137
161
  });
138
162
  log(` -> ${comma(imported.success)} ${groupKey} profiles sent\n`);
139
163
  importResults.groups.push(imported);
@@ -141,7 +165,7 @@ export async function sendToMixpanel(context) {
141
165
  }
142
166
 
143
167
  // Import group events
144
- if (groupEventData?.length > 0) {
168
+ if (groupEventData?.length > 0 || (isBATCH_MODE && groupEventData)) {
145
169
  log(` Group Events`);
146
170
  let groupEventDataToImport = u.deepClone(groupEventData);
147
171
  const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && groupEventData.length === 0);
@@ -149,9 +173,11 @@ export async function sendToMixpanel(context) {
149
173
  const files = groupEventData.getWrittenFiles();
150
174
  if (files.length > 0) groupEventDataToImport = files;
151
175
  }
176
+ const groupEvTotal = Array.isArray(groupEventDataToImport) ? groupEventDataToImport.length : 0;
152
177
  const imported = await mp(creds, groupEventDataToImport, {
153
178
  recordType: "event",
154
179
  ...commonOpts,
180
+ progressCallback: makeProgressCallback(groupEvTotal),
155
181
  });
156
182
  log(` -> ${comma(imported.success)} group events sent\n`);
157
183
  importResults.groupEvents = imported;
@@ -196,6 +222,7 @@ export async function sendToMixpanel(context) {
196
222
  }
197
223
 
198
224
  try {
225
+ const scdTotal = Array.isArray(scdDataToImport) ? scdDataToImport.length : 0;
199
226
  const imported = await mp(
200
227
  {
201
228
  token,
@@ -204,7 +231,7 @@ export async function sendToMixpanel(context) {
204
231
  project: projectId
205
232
  },
206
233
  scdDataToImport,
207
- options
234
+ { ...options, progressCallback: makeProgressCallback(scdTotal) }
208
235
  );
209
236
  log(` -> ${comma(imported.success)} ${scdKey} SCD entries sent\n`);
210
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;
@@ -579,7 +579,16 @@ function streamJSON(filePath, data, options = {}) {
579
579
 
580
580
  if (filePath?.startsWith('gs://')) {
581
581
  const { uri, bucket, file } = parseGCSUri(filePath);
582
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
582
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
583
+ gcsStream.on('finish', () => resolve(filePath));
584
+ gcsStream.on('error', reject);
585
+ if (gzip) {
586
+ const gzipStream = zlib.createGzip();
587
+ gzipStream.pipe(gcsStream);
588
+ writeStream = gzipStream;
589
+ } else {
590
+ writeStream = gcsStream;
591
+ }
583
592
  }
584
593
  else {
585
594
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -593,9 +602,9 @@ function streamJSON(filePath, data, options = {}) {
593
602
  writeStream.write(JSON.stringify(item) + '\n');
594
603
  });
595
604
  writeStream.end();
596
- writeStream.on('finish', () => {
597
- resolve(filePath);
598
- });
605
+ if (!filePath?.startsWith('gs://')) {
606
+ writeStream.on('finish', () => resolve(filePath));
607
+ }
599
608
  writeStream.on('error', reject);
600
609
  });
601
610
  }
@@ -607,7 +616,16 @@ function streamCSV(filePath, data, options = {}) {
607
616
 
608
617
  if (filePath?.startsWith('gs://')) {
609
618
  const { uri, bucket, file } = parseGCSUri(filePath);
610
- writeStream = storage.bucket(bucket).file(file).createWriteStream({ gzip: true });
619
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
620
+ gcsStream.on('finish', () => resolve(filePath));
621
+ gcsStream.on('error', reject);
622
+ if (gzip) {
623
+ const gzipStream = zlib.createGzip();
624
+ gzipStream.pipe(gcsStream);
625
+ writeStream = gzipStream;
626
+ } else {
627
+ writeStream = gcsStream;
628
+ }
611
629
  }
612
630
  else {
613
631
  writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
@@ -635,9 +653,9 @@ function streamCSV(filePath, data, options = {}) {
635
653
  });
636
654
 
637
655
  writeStream.end();
638
- writeStream.on('finish', () => {
639
- resolve(filePath);
640
- });
656
+ if (!filePath?.startsWith('gs://')) {
657
+ writeStream.on('finish', () => resolve(filePath));
658
+ }
641
659
  writeStream.on('error', reject);
642
660
  });
643
661
  }
@@ -710,20 +728,23 @@ async function streamParquet(filePath, data, options = {}) {
710
728
  });
711
729
 
712
730
  if (filePath?.startsWith('gs://')) {
713
- // For GCS, write to buffer first, then upload
714
731
  // @ts-ignore
715
732
  const arrayBuffer = parquetWriteBuffer({ columnData });
716
733
  const { bucket, file } = parseGCSUri(filePath);
717
-
718
- const writeStream = storage.bucket(bucket).file(file).createWriteStream({
719
- gzip: gzip || true // Always gzip for GCS
720
- });
734
+ const gcsStream = storage.bucket(bucket).file(file).createWriteStream();
721
735
 
722
736
  return new Promise((resolve, reject) => {
723
- writeStream.write(Buffer.from(arrayBuffer));
724
- writeStream.end();
725
- writeStream.on('finish', () => resolve(filePath));
726
- writeStream.on('error', reject);
737
+ gcsStream.on('finish', () => resolve(filePath));
738
+ gcsStream.on('error', reject);
739
+ if (gzip) {
740
+ const gzipStream = zlib.createGzip();
741
+ gzipStream.pipe(gcsStream);
742
+ gzipStream.write(Buffer.from(arrayBuffer));
743
+ gzipStream.end();
744
+ } else {
745
+ gcsStream.write(Buffer.from(arrayBuffer));
746
+ gcsStream.end();
747
+ }
727
748
  });
728
749
  } else {
729
750
  // For local files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -26,7 +26,8 @@
26
26
  "scripts/",
27
27
  "package.json",
28
28
  "README.md",
29
- "CHANGELOG.md"
29
+ "CHANGELOG.md",
30
+ "HOOKS.md"
30
31
  ],
31
32
  "engines": {
32
33
  "node": ">=18.0.0"
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
  /**