sfmc-dataloader 2.0.2 → 2.1.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 CHANGED
@@ -101,16 +101,16 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
101
101
 
102
102
  ## Options
103
103
 
104
- | Option | Description |
105
- |--------|-------------|
106
- | `-p, --project` | Project root (default: cwd) |
107
- | `--format` | `csv` (default), `tsv`, or `json` |
108
- | `--git` | Stable export filenames: `<key>.mcdata.<ext>` (no timestamp segment) |
109
- | `--mode` | `upsert` (default) or `insert` — async bulk REST API only |
110
- | `--from <cred>/<bu>` | Export: source BU (repeatable). Import API mode: single source BU (use with `--to` and `--de`) |
111
- | `--to <cred>/<bu>` | Import: target BU (repeatable). API mode: use with `--from`/`--de`. File mode: use with `--file` (no `--from` needed) |
112
- | `--clear-before-import` | SOAP `ClearData` before REST import |
113
- | `--i-accept-clear-data-risk` | Non-interactive consent for clear |
104
+ | Option | Description |
105
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
106
+ | `-p, --project` | Project root (default: cwd) |
107
+ | `--format` | `csv` (default), `tsv`, or `json` |
108
+ | `--git` | Stable export filenames: `<key>.mcdata.<ext>` (no timestamp segment) |
109
+ | `--mode` | `upsert` (default) or `insert` — async bulk REST API only |
110
+ | `--from <cred>/<bu>` | Export: source BU (repeatable). Import API mode: single source BU (use with `--to` and `--de`) |
111
+ | `--to <cred>/<bu>` | Import: target BU (repeatable). API mode: use with `--from`/`--de`. File mode: use with `--file` (no `--from` needed) |
112
+ | `--clear-before-import` | SOAP `ClearData` before REST import |
113
+ | `--i-accept-clear-data-risk` | Non-interactive consent for clear |
114
114
 
115
115
  Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
116
116
 
package/bin/mcdata.mjs CHANGED
@@ -3,7 +3,7 @@ import { main } from '../lib/cli.mjs';
3
3
 
4
4
  main(process.argv)
5
5
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
6
- .catch((err) => {
7
- console.error(err);
6
+ .catch((ex) => {
7
+ console.error(ex);
8
8
  process.exit(1);
9
9
  });
package/lib/clear-de.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @param {*} soap - SDK soap instance
6
6
  * @param {string} customerKey - DE external key
7
- * @returns {Promise<any>}
7
+ * @returns {Promise.<any>}
8
8
  */
9
9
  export async function clearDataExtensionRows(soap, customerKey) {
10
10
  return soap.perform('DataExtension', 'ClearData', {
package/lib/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  parseCredBu,
10
10
  resolveCredentialAndMid,
11
11
  buildSdkAuthObject,
12
+ buildSdkOptions,
12
13
  } from './config.mjs';
13
14
  import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
14
15
  import { exportDataExtensionToFile } from './export-de.mjs';
@@ -19,6 +20,7 @@ import { clearDataExtensionRows } from './clear-de.mjs';
19
20
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
20
21
  import { multiBuExport } from './multi-bu-export.mjs';
21
22
  import { crossBuImport } from './cross-bu-import.mjs';
23
+ import { initDebugLogger } from './debug-logger.mjs';
22
24
 
23
25
  /** @returns {string} semver from this package's package.json */
24
26
  function readCliPackageVersion() {
@@ -44,9 +46,10 @@ Usage:
44
46
  Options:
45
47
  --version Print version and exit
46
48
  -p, --project <dir> mcdev project root (default: cwd)
47
- --format <csv|tsv|json> File format (default: csv)
49
+ --format <csv|tsv|json> Export file format (default: csv); ignored for imports
48
50
  --json-pretty Pretty-print JSON on export
49
51
  --git Stable filenames: <key>.mcdata.<ext> (no timestamp)
52
+ --debug Write API requests/responses to ./logs/data/*.log
50
53
 
51
54
  Import options:
52
55
  --mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
@@ -61,8 +64,9 @@ Multi-BU options:
61
64
 
62
65
  Notes:
63
66
  Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
64
- Import with --de resolves the latest matching file in that folder (by mtime).
67
+ Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
65
68
  Import with --file parses the DE key from the basename (.mcdata. format).
69
+ Import format is auto-detected from file extension (.csv, .tsv, .json).
66
70
  Cross-BU import stores a download file in each target BU's data directory.
67
71
 
68
72
  Clear data warning:
@@ -73,7 +77,7 @@ Clear data warning:
73
77
 
74
78
  /**
75
79
  * @param {string[]} argv
76
- * @returns {Promise<number>} exit code
80
+ * @returns {Promise.<number>} exit code
77
81
  */
78
82
  export async function main(argv) {
79
83
  let values;
@@ -95,14 +99,15 @@ export async function main(argv) {
95
99
  'clear-before-import': { type: 'boolean', default: false },
96
100
  'i-accept-clear-data-risk': { type: 'boolean', default: false },
97
101
  'json-pretty': { type: 'boolean', default: false },
102
+ debug: { type: 'boolean', default: false },
98
103
  help: { type: 'boolean', short: 'h', default: false },
99
104
  version: { type: 'boolean', default: false },
100
105
  },
101
106
  });
102
107
  values = parsed.values;
103
108
  positionals = parsed.positionals;
104
- } catch (e) {
105
- console.error(e.message);
109
+ } catch (ex) {
110
+ console.error(ex.message);
106
111
  printHelp();
107
112
  return 1;
108
113
  }
@@ -137,6 +142,15 @@ export async function main(argv) {
137
142
  const hasTo = toFlags.length > 0;
138
143
  const hasPositional = !!credBuRaw;
139
144
 
145
+ // Initialize debug logger if --debug flag is set
146
+ /** @type {import('./debug-logger.mjs').DebugLogger|null} */
147
+ const logger = values.debug
148
+ ? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
149
+ : null;
150
+ if (logger) {
151
+ console.error(`Debug log: ${projectRelativePosix(projectRoot, logger.logPath)}`);
152
+ }
153
+
140
154
  // ── export ──────────────────────────────────────────────────────────────
141
155
  if (sub === 'export') {
142
156
  if (hasTo) {
@@ -144,19 +158,23 @@ export async function main(argv) {
144
158
  return 1;
145
159
  }
146
160
 
147
- const des = [].concat(values.de ?? []);
161
+ const des = [values.de ?? []].flat();
148
162
  if (des.length === 0) {
149
163
  console.error('export requires at least one --de <customerKey>');
150
164
  return 1;
151
165
  }
152
166
 
153
167
  if (hasFrom && hasPositional) {
154
- console.error('Cannot mix a positional <credential>/<bu> with --from. Use one or the other.');
168
+ console.error(
169
+ 'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
170
+ );
155
171
  return 1;
156
172
  }
157
173
 
158
174
  if (!hasFrom && !hasPositional) {
159
- console.error('export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.');
175
+ console.error(
176
+ 'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
177
+ );
160
178
  printHelp();
161
179
  return 1;
162
180
  }
@@ -165,8 +183,8 @@ export async function main(argv) {
165
183
  let sources;
166
184
  try {
167
185
  sources = fromFlags.map(parseCredBu);
168
- } catch (e) {
169
- console.error(e.message);
186
+ } catch (ex) {
187
+ console.error(ex.message);
170
188
  return 1;
171
189
  }
172
190
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -179,6 +197,7 @@ export async function main(argv) {
179
197
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
180
198
  jsonPretty: values['json-pretty'],
181
199
  useGit,
200
+ logger,
182
201
  });
183
202
  return 0;
184
203
  }
@@ -186,7 +205,7 @@ export async function main(argv) {
186
205
  const { credential, bu } = parseCredBu(credBuRaw);
187
206
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
188
207
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
189
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
208
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
190
209
  for (const deKey of des) {
191
210
  const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
192
211
  projectRoot,
@@ -217,19 +236,23 @@ export async function main(argv) {
217
236
  // ── File-to-multi-BU import: --to + --file (no --from) ─────────────
218
237
  if (hasTo && !hasFrom && values.file?.length > 0) {
219
238
  if (hasPositional) {
220
- console.error('Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.');
239
+ console.error(
240
+ 'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
241
+ );
221
242
  return 1;
222
243
  }
223
244
  if (values.de?.length > 0) {
224
- console.error('Cannot mix --de with --file in multi-target import. Use --file only.');
245
+ console.error(
246
+ 'Cannot mix --de with --file in multi-target import. Use --file only.',
247
+ );
225
248
  return 1;
226
249
  }
227
250
  const filePaths = values.file;
228
251
  let targets;
229
252
  try {
230
253
  targets = toFlags.map(parseCredBu);
231
- } catch (e) {
232
- console.error(e.message);
254
+ } catch (ex) {
255
+ console.error(ex.message);
233
256
  return 1;
234
257
  }
235
258
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -245,6 +268,7 @@ export async function main(argv) {
245
268
  acceptRiskFlag: acceptRisk,
246
269
  isTTY: process.stdin.isTTY === true,
247
270
  useGit,
271
+ logger,
248
272
  });
249
273
  return 0;
250
274
  }
@@ -252,26 +276,36 @@ export async function main(argv) {
252
276
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
253
277
  if (hasFrom || hasTo) {
254
278
  if (hasPositional) {
255
- console.error('Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.');
279
+ console.error(
280
+ 'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
281
+ );
256
282
  return 1;
257
283
  }
258
284
  if (!hasFrom) {
259
- console.error('--to requires --from <cred>/<bu> to specify the source Business Unit.');
285
+ console.error(
286
+ '--to requires --from <cred>/<bu> to specify the source Business Unit.',
287
+ );
260
288
  return 1;
261
289
  }
262
290
  if (!hasTo) {
263
- console.error('--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).');
291
+ console.error(
292
+ '--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
293
+ );
264
294
  return 1;
265
295
  }
266
296
  if (fromFlags.length > 1) {
267
- console.error('import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).');
297
+ console.error(
298
+ 'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
299
+ );
268
300
  return 1;
269
301
  }
270
302
  if (values.file?.length > 0) {
271
- console.error('--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).');
303
+ console.error(
304
+ '--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).',
305
+ );
272
306
  return 1;
273
307
  }
274
- const deKeys = [].concat(values.de ?? []);
308
+ const deKeys = [values.de ?? []].flat();
275
309
  if (deKeys.length === 0) {
276
310
  console.error('Cross-BU import requires at least one --de <customerKey>.');
277
311
  return 1;
@@ -281,8 +315,8 @@ export async function main(argv) {
281
315
  try {
282
316
  sourceParsed = parseCredBu(fromFlags[0]);
283
317
  targets = toFlags.map(parseCredBu);
284
- } catch (e) {
285
- console.error(e.message);
318
+ } catch (ex) {
319
+ console.error(ex.message);
286
320
  return 1;
287
321
  }
288
322
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -300,13 +334,16 @@ export async function main(argv) {
300
334
  acceptRiskFlag: acceptRisk,
301
335
  isTTY: process.stdin.isTTY === true,
302
336
  useGit,
337
+ logger,
303
338
  });
304
339
  return 0;
305
340
  }
306
341
 
307
342
  // ── Single-BU import (original behavior) ────────────────────────────
308
343
  if (!hasPositional) {
309
- console.error('import requires either a positional <credential>/<bu> or --from/--to flags.');
344
+ console.error(
345
+ 'import requires either a positional <credential>/<bu> or --from/--to flags.',
346
+ );
310
347
  printHelp();
311
348
  return 1;
312
349
  }
@@ -314,17 +351,19 @@ export async function main(argv) {
314
351
  const hasDe = values.de?.length > 0;
315
352
  const hasFile = values.file?.length > 0;
316
353
  if (hasDe === hasFile) {
317
- console.error('import requires exactly one of: repeated --de <key> OR repeated --file <path>');
354
+ console.error(
355
+ 'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
356
+ );
318
357
  return 1;
319
358
  }
320
359
 
321
360
  const { credential, bu } = parseCredBu(credBuRaw);
322
361
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
323
362
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
324
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
363
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
325
364
 
326
365
  if (hasDe) {
327
- const deKeys = [].concat(values.de ?? []);
366
+ const deKeys = [values.de ?? []].flat();
328
367
  const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
329
368
  if (clear) {
330
369
  await confirmClearBeforeImport({
@@ -337,9 +376,11 @@ export async function main(argv) {
337
376
  }
338
377
  }
339
378
  for (const deKey of deKeys) {
340
- const candidates = await findImportCandidates(dataDir, deKey, fmt);
379
+ const candidates = await findImportCandidates(dataDir, deKey);
341
380
  if (candidates.length === 0) {
342
- console.error(`No ${fmt} file found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`);
381
+ console.error(
382
+ `No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
383
+ );
343
384
  return 1;
344
385
  }
345
386
  const filePath =
@@ -347,7 +388,6 @@ export async function main(argv) {
347
388
  const n = await importFromFile(sdk, {
348
389
  filePath,
349
390
  deKey,
350
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
351
391
  mode: /** @type {'upsert'|'insert'} */ (mode),
352
392
  });
353
393
  const rel = projectRelativePosix(projectRoot, filePath);
@@ -357,7 +397,9 @@ export async function main(argv) {
357
397
  }
358
398
 
359
399
  const fileList = values.file ?? [];
360
- const keysFromFiles = fileList.map((fp) => parseExportBasename(path.basename(fp)).customerKey);
400
+ const keysFromFiles = fileList.map(
401
+ (fp) => parseExportBasename(path.basename(fp)).customerKey,
402
+ );
361
403
  if (clear) {
362
404
  await confirmClearBeforeImport({
363
405
  deKeys: keysFromFiles,
@@ -374,7 +416,6 @@ export async function main(argv) {
374
416
  const n = await importFromFile(sdk, {
375
417
  filePath,
376
418
  deKey: customerKey,
377
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
378
419
  mode: /** @type {'upsert'|'insert'} */ (mode),
379
420
  });
380
421
  const rel = projectRelativePosix(projectRoot, filePath);
package/lib/config.mjs CHANGED
@@ -67,20 +67,22 @@ export function resolveCredentialAndMid(mcdevrc, mcdevAuth, credentialName, buNa
67
67
  if (midRaw === undefined || midRaw === null) {
68
68
  throw new Error(`Unknown business unit "${buName}" under credential "${credentialName}"`);
69
69
  }
70
- const mid =
71
- typeof midRaw === 'number' ? midRaw : Number.parseInt(String(midRaw), 10);
70
+ const mid = typeof midRaw === 'number' ? midRaw : Number.parseInt(String(midRaw), 10);
72
71
  if (!Number.isInteger(mid)) {
73
- throw new Error(`Invalid MID for ${credentialName}/${buName}: ${midRaw}`);
72
+ throw new TypeError(`Invalid MID for ${credentialName}/${buName}: ${midRaw}`);
74
73
  }
75
74
  const authCred = mcdevAuth[credentialName];
76
75
  if (!authCred?.client_id || !authCred?.client_secret || !authCred?.auth_url) {
77
- throw new Error(`Missing auth fields for credential "${credentialName}" in .mcdev-auth.json`);
76
+ throw new Error(
77
+ `Missing auth fields for credential "${credentialName}" in .mcdev-auth.json`,
78
+ );
78
79
  }
79
80
  return { mid, authCred };
80
81
  }
81
82
 
82
83
  /**
83
84
  * Auth object for sfmc-sdk `Auth` / `SDK` constructor.
85
+ *
84
86
  * @param {AuthCredential} authCred
85
87
  * @param {number} mid
86
88
  * @returns {import('sfmc-sdk').AuthObject}
@@ -93,3 +95,48 @@ export function buildSdkAuthObject(authCred, mid) {
93
95
  account_id: mid,
94
96
  };
95
97
  }
98
+
99
+ /**
100
+ * @typedef {object} DebugLogger
101
+ * @property {string} logPath - Absolute path to the log file
102
+ * @property {(text: string) => void} write - Append a line to the log file
103
+ */
104
+
105
+ /**
106
+ * Options object for sfmc-sdk `SDK` constructor.
107
+ * When a logger is provided, includes event handlers to log API requests/responses to file.
108
+ *
109
+ * @param {DebugLogger|null} [logger] - Debug logger object, or null/undefined to disable logging
110
+ * @returns {import('sfmc-sdk').SdkOptions}
111
+ */
112
+ export function buildSdkOptions(logger = null) {
113
+ /** @type {import('sfmc-sdk').SdkOptions} */
114
+ const options = { requestAttempts: 3 };
115
+ if (logger) {
116
+ options.eventHandlers = {
117
+ logRequest: (req) => {
118
+ const msg = structuredClone(req);
119
+ if (msg.headers?.Authorization) {
120
+ msg.headers.Authorization = 'Bearer *** TOKEN REMOVED ***';
121
+ }
122
+ logger.write(`API REQUEST >> ${msg.method?.toUpperCase() || 'GET'} ${msg.url}`);
123
+ if (msg.data) {
124
+ const body =
125
+ typeof msg.data === 'string' ? msg.data : JSON.stringify(msg.data, null, 2);
126
+ logger.write(`REQUEST BODY >> ${body}`);
127
+ }
128
+ },
129
+ logResponse: (res) => {
130
+ logger.write(`API RESPONSE << ${res.status || res.statusCode || '(no status)'}`);
131
+ const body =
132
+ typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2);
133
+ const indentedBody = body
134
+ .split('\n')
135
+ .map((line) => ' ' + line)
136
+ .join('\n');
137
+ logger.write(`RESPONSE BODY <<\n${indentedBody}`);
138
+ },
139
+ };
140
+ }
141
+ return options;
142
+ }
@@ -10,10 +10,10 @@ import { stdin as input, stdout as output } from 'node:process';
10
10
  * @param {string[]} opts.deKeys
11
11
  * @param {boolean} opts.acceptRiskFlag
12
12
  * @param {boolean} opts.isTTY
13
- * @param {CredBuTarget[]} [opts.targets] When present, renders a per-BU breakdown.
13
+ * @param {CredBuTarget[]} [opts.targets] When present, renders a per-BU breakdown.
14
14
  * @param {NodeJS.ReadableStream} [opts.stdin]
15
15
  * @param {NodeJS.WritableStream} [opts.stdout]
16
- * @returns {Promise<void>}
16
+ * @returns {Promise.<void>}
17
17
  */
18
18
  export async function confirmClearBeforeImport(opts) {
19
19
  const { deKeys, targets, acceptRiskFlag, isTTY } = opts;
@@ -25,30 +25,26 @@ export async function confirmClearBeforeImport(opts) {
25
25
  if (!isTTY) {
26
26
  throw new Error(
27
27
  'Refusing to clear data in non-interactive mode without --i-accept-clear-data-risk. ' +
28
- 'All rows in the target Data Extension(s) would be permanently deleted.'
28
+ 'All rows in the target Data Extension(s) would be permanently deleted.',
29
29
  );
30
30
  }
31
- let msg;
32
- if (targets && targets.length > 0) {
33
- msg =
34
- '\n*** DANGER: CLEAR DATA ***\n' +
35
- `This will permanently DELETE ALL ROWS across ${targets.length} Business Unit(s):\n\n` +
36
- targets
37
- .map(
38
- ({ credential, bu }) =>
39
- ` ${credential}/${bu}:\n` + deKeys.map((k) => ` - ${k}\n`).join('')
40
- )
41
- .join('') +
42
- '\nThis cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
43
- 'Type YES to continue, anything else to abort: ';
44
- } else {
45
- msg =
46
- '\n*** DANGER: CLEAR DATA ***\n' +
47
- 'This will permanently DELETE ALL ROWS in:\n' +
48
- deKeys.map((k) => ` - ${k}\n`).join('') +
49
- 'This cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
50
- 'Type YES to continue, anything else to abort: ';
51
- }
31
+ const msg =
32
+ targets && targets.length > 0
33
+ ? '\n*** DANGER: CLEAR DATA ***\n' +
34
+ `This will permanently DELETE ALL ROWS across ${targets.length} Business Unit(s):\n\n` +
35
+ targets
36
+ .map(
37
+ ({ credential, bu }) =>
38
+ ` ${credential}/${bu}:\n` + deKeys.map((k) => ` - ${k}\n`).join(''),
39
+ )
40
+ .join('') +
41
+ '\nThis cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
42
+ 'Type YES to continue, anything else to abort: '
43
+ : '\n*** DANGER: CLEAR DATA ***\n' +
44
+ 'This will permanently DELETE ALL ROWS in:\n' +
45
+ deKeys.map((k) => ` - ${k}\n`).join('') +
46
+ 'This cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
47
+ 'Type YES to continue, anything else to abort: ';
52
48
  stdout.write(msg);
53
49
  const rl = readline.createInterface({ input: stdin, output: stdout });
54
50
  try {
@@ -3,8 +3,9 @@ import path from 'node:path';
3
3
  import readline from 'node:readline/promises';
4
4
  import { stdin as input, stdout as output } from 'node:process';
5
5
  import SDK from 'sfmc-sdk';
6
- import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
6
+ import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
7
7
  import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
8
+ import { formatFromExtension } from './file-resolve.mjs';
8
9
  import { importRowsForDe } from './import-de.mjs';
9
10
  import { readRowsFromFile } from './read-rows.mjs';
10
11
  import { clearDataExtensionRows } from './clear-de.mjs';
@@ -25,7 +26,7 @@ import { buildExportBasename, filesystemSafeTimestamp, parseExportBasename } fro
25
26
  * @param {string[]} opts.deKeys
26
27
  * @param {NodeJS.ReadableStream} [opts.stdin]
27
28
  * @param {NodeJS.WritableStream} [opts.stdout]
28
- * @returns {Promise<boolean>}
29
+ * @returns {Promise.<boolean>}
29
30
  */
30
31
  async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdout: stdoutStream }) {
31
32
  const stdinSrc = stdinStream ?? input;
@@ -66,8 +67,8 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
66
67
  * @param {import('./config.mjs').Mcdevrc} params.mcdevrc
67
68
  * @param {Record<string, import('./config.mjs').AuthCredential>} params.mcdevAuth
68
69
  * @param {string} [params.sourceCred] - API mode only
69
- * @param {string} [params.sourceBu] - API mode only
70
- * @param {string[]} [params.deKeys] - API mode only
70
+ * @param {string} [params.sourceBu] - API mode only
71
+ * @param {string[]} [params.deKeys] - API mode only
71
72
  * @param {string[]} [params.filePaths] - File mode only; mutually exclusive with sourceCred/sourceBu/deKeys
72
73
  * @param {CredBuTarget[]} params.targets
73
74
  * @param {'csv'|'tsv'|'json'} params.format
@@ -76,9 +77,10 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
76
77
  * @param {boolean} params.acceptRiskFlag
77
78
  * @param {boolean} params.isTTY
78
79
  * @param {boolean} [params.useGit] - stable snapshot basename (no timestamp)
79
- * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
80
+ * @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
81
+ * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
80
82
  * @param {NodeJS.WritableStream} [params.stdout] Override for testing
81
- * @returns {Promise<void>}
83
+ * @returns {Promise.<void>}
82
84
  */
83
85
  export async function crossBuImport(params) {
84
86
  const {
@@ -92,6 +94,7 @@ export async function crossBuImport(params) {
92
94
  acceptRiskFlag,
93
95
  isTTY,
94
96
  useGit = false,
97
+ logger = null,
95
98
  } = params;
96
99
  const stdin = params.stdin;
97
100
  const stdout = params.stdout;
@@ -123,9 +126,12 @@ export async function crossBuImport(params) {
123
126
  let srcSdk = null;
124
127
  if (!isFileBased) {
125
128
  const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(
126
- mcdevrc, mcdevAuth, params.sourceCred, params.sourceBu
129
+ mcdevrc,
130
+ mcdevAuth,
131
+ params.sourceCred,
132
+ params.sourceBu,
127
133
  );
128
- srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), { requestAttempts: 3 });
134
+ srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), buildSdkOptions(logger));
129
135
  }
130
136
 
131
137
  // Optional pre-import backup of target BU data
@@ -133,8 +139,13 @@ export async function crossBuImport(params) {
133
139
  const doBackup = await offerPreExportBackup({ targets, deKeys, stdin, stdout });
134
140
  if (doBackup) {
135
141
  for (const { credential, bu } of targets) {
136
- const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
137
- const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
142
+ const { mid, authCred } = resolveCredentialAndMid(
143
+ mcdevrc,
144
+ mcdevAuth,
145
+ credential,
146
+ bu,
147
+ );
148
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
138
149
  for (const deKey of deKeys) {
139
150
  const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
140
151
  projectRoot,
@@ -158,22 +169,37 @@ export async function crossBuImport(params) {
158
169
 
159
170
  // Load rows once per DE then fan out to every target
160
171
  for (const deKey of deKeys) {
161
- const rows = isFileBased
162
- ? await readRowsFromFile(fileByDeKey.get(deKey), format)
163
- : await fetchAllRowObjects(srcSdk, deKey);
172
+ let rows;
173
+ if (isFileBased) {
174
+ const filePath = fileByDeKey.get(deKey);
175
+ const detectedFormat = formatFromExtension(filePath);
176
+ if (!detectedFormat) {
177
+ throw new Error(
178
+ `Cannot determine format for file: ${filePath}. Use .csv, .tsv, or .json extension.`,
179
+ );
180
+ }
181
+ rows = await readRowsFromFile(filePath, detectedFormat);
182
+ } else {
183
+ rows = await fetchAllRowObjects(srcSdk, deKey);
184
+ }
164
185
 
165
186
  // Clear targets before import (rows already confirmed above)
166
187
  if (clearBeforeImport) {
167
188
  for (const { credential, bu } of targets) {
168
- const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
169
- const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
189
+ const { mid, authCred } = resolveCredentialAndMid(
190
+ mcdevrc,
191
+ mcdevAuth,
192
+ credential,
193
+ bu,
194
+ );
195
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
170
196
  await clearDataExtensionRows(tgtSdk.soap, deKey);
171
197
  }
172
198
  }
173
199
 
174
200
  for (const { credential, bu } of targets) {
175
201
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
176
- const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
202
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
177
203
 
178
204
  // Write a snapshot file in the target BU's data directory.
179
205
  const dir = dataDirectoryForBu(projectRoot, credential, bu);
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * @typedef {object} DebugLogger
6
+ * @property {string} logPath - Absolute path to the log file
7
+ * @property {(text: string) => void} write - Append a line to the log file
8
+ */
9
+
10
+ /**
11
+ * Initialize a debug logger that writes API interactions to a timestamped log file.
12
+ *
13
+ * @param {string} projectRoot - mcdev project root directory
14
+ * @param {string} version - mcdata version string
15
+ * @param {string[]} argv - Full process.argv array
16
+ * @returns {DebugLogger}
17
+ */
18
+ export function initDebugLogger(projectRoot, version, argv) {
19
+ const logsDir = path.join(projectRoot, 'logs', 'data');
20
+ fs.mkdirSync(logsDir, { recursive: true });
21
+
22
+ // Timestamp with dots instead of colons for Windows filesystem compatibility
23
+ const ts = new Date().toISOString().replaceAll(':', '.');
24
+ const logPath = path.join(logsDir, `${ts}.log`);
25
+
26
+ // Reconstruct command line for header, quoting args with spaces
27
+ const command =
28
+ 'mcdata ' +
29
+ argv
30
+ .slice(2)
31
+ .map((arg) => (arg.includes(' ') ? `"${arg}"` : arg))
32
+ .join(' ');
33
+
34
+ // Write header
35
+ const header = `mcdata v${version}\nRan command: ${command}\n---\n`;
36
+ fs.writeFileSync(logPath, header, 'utf8');
37
+
38
+ return {
39
+ logPath,
40
+ write: (text) => fs.appendFileSync(logPath, text + '\n', 'utf8'),
41
+ };
42
+ }
package/lib/export-de.mjs CHANGED
@@ -6,13 +6,22 @@ import { buildExportBasename, filesystemSafeTimestamp } from './filename.mjs';
6
6
  import { dataDirectoryForBu } from './paths.mjs';
7
7
 
8
8
  /**
9
- * @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise<any> } }} sdk
9
+ * @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
10
10
  * @param {string} deKey
11
- * @returns {Promise<object[]>}
11
+ * @returns {Promise.<object[]>}
12
12
  */
13
13
  export async function fetchAllRowObjects(sdk, deKey) {
14
14
  const basePath = rowsetGetPath(deKey);
15
- const data = await sdk.rest.getBulk(basePath, 2500);
15
+ let data;
16
+ try {
17
+ data = await sdk.rest.getBulk(basePath, 2500);
18
+ } catch (ex) {
19
+ // this api endpoint won't return "items" if the dataExtension is empty
20
+ if (ex.message !== 'Could not find an array to iterate over') {
21
+ throw ex;
22
+ }
23
+ data = { items: [] };
24
+ }
16
25
  const items = data.items ?? [];
17
26
  const rows = [];
18
27
  for (const item of items) {
@@ -35,14 +44,14 @@ export function serializeRows(rows, format, jsonPretty) {
35
44
  const delimiter = format === 'tsv' ? '\t' : ',';
36
45
  return stringify(rows, {
37
46
  header: true,
38
- quoted: true,
47
+ quoted: format === 'csv',
39
48
  bom: true,
40
49
  delimiter,
41
50
  });
42
51
  }
43
52
 
44
53
  /**
45
- * @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise<any> } }} sdk
54
+ * @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
46
55
  * @param {object} params
47
56
  * @param {string} params.projectRoot
48
57
  * @param {string} params.credentialName
@@ -51,10 +60,18 @@ export function serializeRows(rows, format, jsonPretty) {
51
60
  * @param {'csv'|'tsv'|'json'} params.format
52
61
  * @param {boolean} [params.jsonPretty]
53
62
  * @param {boolean} [params.useGit]
54
- * @returns {Promise<{ path: string, rowCount: number }>}
63
+ * @returns {Promise.<{path: string, rowCount: number}>}
55
64
  */
56
65
  export async function exportDataExtensionToFile(sdk, params) {
57
- const { projectRoot, credentialName, buName, deKey, format, jsonPretty = false, useGit = false } = params;
66
+ const {
67
+ projectRoot,
68
+ credentialName,
69
+ buName,
70
+ deKey,
71
+ format,
72
+ jsonPretty = false,
73
+ useGit = false,
74
+ } = params;
58
75
  const rows = await fetchAllRowObjects(sdk, deKey);
59
76
  const dir = dataDirectoryForBu(projectRoot, credentialName, buName);
60
77
  await fs.mkdir(dir, { recursive: true });
@@ -2,13 +2,37 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseExportBasename } from './filename.mjs';
4
4
 
5
+ /** Supported import/export file extensions */
6
+ const SUPPORTED_EXTENSIONS = ['csv', 'tsv', 'json'];
7
+
8
+ /**
9
+ * Derive format from file extension.
10
+ *
11
+ * @param {string} filePath
12
+ * @returns {'csv'|'tsv'|'json'|null}
13
+ */
14
+ export function formatFromExtension(filePath) {
15
+ const ext = path.extname(filePath).toLowerCase().slice(1);
16
+ if (ext === 'csv') {
17
+ return 'csv';
18
+ }
19
+ if (ext === 'tsv') {
20
+ return 'tsv';
21
+ }
22
+ if (ext === 'json') {
23
+ return 'json';
24
+ }
25
+ return null;
26
+ }
27
+
5
28
  /**
6
29
  * Find export files under data dir matching the DE customer key and extension.
30
+ * When format is omitted, searches all supported extensions (csv, tsv, json).
7
31
  *
8
32
  * @param {string} dataDir
9
33
  * @param {string} customerKey
10
- * @param {'csv'|'tsv'|'json'} format
11
- * @returns {Promise<string[]>} full paths
34
+ * @param {'csv'|'tsv'|'json'} [format] - optional; if omitted, searches all extensions
35
+ * @returns {Promise.<string[]>} full paths
12
36
  */
13
37
  export async function findImportCandidates(dataDir, customerKey, format) {
14
38
  let entries;
@@ -17,14 +41,15 @@ export async function findImportCandidates(dataDir, customerKey, format) {
17
41
  } catch {
18
42
  return [];
19
43
  }
20
- const ext = format;
44
+ const extensions = format ? [format] : SUPPORTED_EXTENSIONS;
21
45
  const matches = [];
22
46
  for (const ent of entries) {
23
47
  if (!ent.isFile()) {
24
48
  continue;
25
49
  }
26
50
  const name = ent.name;
27
- if (!name.endsWith(`.${ext}`)) {
51
+ const fileExt = path.extname(name).toLowerCase().slice(1);
52
+ if (!extensions.includes(fileExt)) {
28
53
  continue;
29
54
  }
30
55
  try {
@@ -41,7 +66,7 @@ export async function findImportCandidates(dataDir, customerKey, format) {
41
66
 
42
67
  /**
43
68
  * @param {string[]} paths
44
- * @returns {Promise<string>} path with newest mtime
69
+ * @returns {Promise.<string>} path with newest mtime
45
70
  */
46
71
  export async function pickLatestByMtime(paths) {
47
72
  if (paths.length === 0) {
package/lib/filename.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Mirrors sfmc-devtools `File.filterIllegalFilenames` / `reverseFilterIllegalFilenames`
3
3
  * so export filenames stay consistent with mcdev retrieve-style paths.
4
+ *
4
5
  * @see https://github.com/Accenture/sfmc-devtools (lib/util/file.js)
5
6
  */
6
7
 
@@ -12,22 +13,20 @@ export const MCDATA_SEGMENT = '.mcdata.';
12
13
  * @returns {string}
13
14
  */
14
15
  export function filterIllegalFilenames(filename) {
15
- return (
16
- encodeURIComponent(filename)
17
- .replaceAll(/[*]/g, '_STAR_')
18
- .split('%20')
19
- .join(' ')
20
- .split('%7B')
21
- .join('{')
22
- .split('%7D')
23
- .join('}')
24
- .split('%5B')
25
- .join('[')
26
- .split('%5D')
27
- .join(']')
28
- .split('%40')
29
- .join('@')
30
- );
16
+ return encodeURIComponent(filename)
17
+ .replaceAll(/[*]/g, '_STAR_')
18
+ .split('%20')
19
+ .join(' ')
20
+ .split('%7B')
21
+ .join('{')
22
+ .split('%7D')
23
+ .join('}')
24
+ .split('%5B')
25
+ .join('[')
26
+ .split('%5D')
27
+ .join(']')
28
+ .split('%40')
29
+ .join('@');
31
30
  }
32
31
 
33
32
  /**
@@ -90,5 +89,7 @@ export function parseExportBasename(basename) {
90
89
  };
91
90
  }
92
91
 
93
- throw new Error(`Filename must contain ".mcdata." or end with ".mcdata" before the extension: ${basename}`);
92
+ throw new Error(
93
+ `Filename must contain ".mcdata." or end with ".mcdata" before the extension: ${basename}`,
94
+ );
94
95
  }
package/lib/import-de.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chunkItemsForPayload } from './batch.mjs';
2
+ import { formatFromExtension } from './file-resolve.mjs';
2
3
  import { resolveImportRoute } from './import-routes.mjs';
3
4
  import { withRetry429 } from './retry.mjs';
4
5
  import { readRowsFromFile } from './read-rows.mjs';
@@ -9,7 +10,7 @@ import { readRowsFromFile } from './read-rows.mjs';
9
10
  * @param {string} params.deKey
10
11
  * @param {object[]} params.rows
11
12
  * @param {'upsert'|'insert'} params.mode
12
- * @returns {Promise<number>} number of rows imported
13
+ * @returns {Promise.<number>} number of rows imported
13
14
  */
14
15
  export async function importRowsForDe(sdk, params) {
15
16
  const { deKey, rows, mode } = params;
@@ -19,9 +20,7 @@ export async function importRowsForDe(sdk, params) {
19
20
  const p = route.path(deKey);
20
21
  const body = { items: chunk };
21
22
  await withRetry429(() =>
22
- route.method === 'PUT'
23
- ? sdk.rest.put(p, body)
24
- : sdk.rest.post(p, body)
23
+ route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
25
24
  );
26
25
  }
27
26
  return rows.length;
@@ -32,12 +31,18 @@ export async function importRowsForDe(sdk, params) {
32
31
  * @param {object} params
33
32
  * @param {string} params.filePath
34
33
  * @param {string} params.deKey - target DE customer key for API
35
- * @param {'csv'|'tsv'|'json'} params.format
34
+ * @param {'csv'|'tsv'|'json'} [params.format] - optional; auto-detected from file extension if omitted
36
35
  * @param {'upsert'|'insert'} params.mode
37
- * @returns {Promise<number>} number of rows imported
36
+ * @returns {Promise.<number>} number of rows imported
38
37
  */
39
38
  export async function importFromFile(sdk, params) {
40
- const rows = await readRowsFromFile(params.filePath, params.format);
39
+ const format = params.format || formatFromExtension(params.filePath);
40
+ if (!format) {
41
+ throw new Error(
42
+ `Cannot determine format for file: ${params.filePath}. Use .csv, .tsv, or .json extension.`,
43
+ );
44
+ }
45
+ const rows = await readRowsFromFile(params.filePath, format);
41
46
  return importRowsForDe(sdk, {
42
47
  deKey: params.deKey,
43
48
  rows,
@@ -13,6 +13,7 @@ export function rowsetGetPath(deKey) {
13
13
 
14
14
  /**
15
15
  * Async bulk row writes: POST insert, PUT upsert (same URL).
16
+ *
16
17
  * @param {string} deKey
17
18
  * @returns {string}
18
19
  */
package/lib/index.mjs CHANGED
@@ -1,8 +1,17 @@
1
1
  export { main } from './cli.mjs';
2
- export { filterIllegalFilenames, reverseFilterIllegalFilenames, parseExportBasename } from './filename.mjs';
2
+ export {
3
+ filterIllegalFilenames,
4
+ reverseFilterIllegalFilenames,
5
+ parseExportBasename,
6
+ } from './filename.mjs';
3
7
  export { chunkItemsForPayload, DEFAULT_MAX_BODY_BYTES, MAX_OBJECTS_PER_BATCH } from './batch.mjs';
4
8
  export { resolveImportRoute, rowsetGetPath, asyncDataExtensionRowsPath } from './import-routes.mjs';
5
- export { loadMcdevProject, parseCredBu, resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
9
+ export {
10
+ loadMcdevProject,
11
+ parseCredBu,
12
+ resolveCredentialAndMid,
13
+ buildSdkAuthObject,
14
+ } from './config.mjs';
6
15
  export { multiBuExport } from './multi-bu-export.mjs';
7
16
  export { crossBuImport } from './cross-bu-import.mjs';
8
17
  export { projectRelativePosix } from './paths.mjs';
@@ -1,5 +1,5 @@
1
1
  import SDK from 'sfmc-sdk';
2
- import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
2
+ import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
3
3
  import { exportDataExtensionToFile } from './export-de.mjs';
4
4
  import { projectRelativePosix } from './paths.mjs';
5
5
 
@@ -21,14 +21,25 @@ import { projectRelativePosix } from './paths.mjs';
21
21
  * @param {'csv'|'tsv'|'json'} params.format
22
22
  * @param {boolean} [params.jsonPretty]
23
23
  * @param {boolean} [params.useGit]
24
- * @returns {Promise<string[]>} Paths of all written files
24
+ * @param {import('./config.mjs').DebugLogger|null} [params.logger]
25
+ * @returns {Promise.<string[]>} Paths of all written files
25
26
  */
26
- export async function multiBuExport({ projectRoot, mcdevrc, mcdevAuth, sources, deKeys, format, jsonPretty = false, useGit = false }) {
27
+ export async function multiBuExport({
28
+ projectRoot,
29
+ mcdevrc,
30
+ mcdevAuth,
31
+ sources,
32
+ deKeys,
33
+ format,
34
+ jsonPretty = false,
35
+ useGit = false,
36
+ logger = null,
37
+ }) {
27
38
  /** @type {string[]} */
28
39
  const exported = [];
29
40
  for (const { credential, bu } of sources) {
30
41
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
31
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
42
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
32
43
  for (const deKey of deKeys) {
33
44
  const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
34
45
  projectRoot,
package/lib/read-rows.mjs CHANGED
@@ -4,7 +4,7 @@ import csv from 'csv-parser';
4
4
  /**
5
5
  * @param {string} filePath
6
6
  * @param {'csv'|'tsv'|'json'} format
7
- * @returns {Promise<object[]>}
7
+ * @returns {Promise.<object[]>}
8
8
  */
9
9
  export async function readRowsFromFile(filePath, format) {
10
10
  if (format === 'json') {
@@ -26,7 +26,26 @@ export async function readRowsFromFile(filePath, format) {
26
26
  csv({
27
27
  separator: delimiter,
28
28
  bom: true,
29
- })
29
+ mapHeaders: ({ header }) => {
30
+ let h = header;
31
+ // Strip BOM if present (backup in case bom:true misses it)
32
+ if (h.codePointAt(0) === 0xFEFF) {
33
+ h = h.slice(1);
34
+ }
35
+ // Strip surrounding quotes if present (non-standard quoted TSV)
36
+ if (h.startsWith('"') && h.endsWith('"') && h.length >= 2) {
37
+ h = h.slice(1, -1);
38
+ }
39
+ return h;
40
+ },
41
+ mapValues: ({ value }) => {
42
+ // Strip surrounding quotes from values if present
43
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
44
+ return value.slice(1, -1);
45
+ }
46
+ return value;
47
+ },
48
+ }),
30
49
  )
31
50
  .on('data', (row) => rows.push(row))
32
51
  .on('end', () => resolve(rows))
package/lib/retry.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { RestError } from 'sfmc-sdk/util';
2
2
 
3
3
  /**
4
- * @param {() => Promise<any>} fn
4
+ * @param {() => Promise.<any>} fn
5
5
  * @param {object} [opts]
6
6
  * @param {number} [opts.maxAttempts] default 5
7
- * @returns {Promise<any>}
7
+ * @returns {Promise.<any>}
8
8
  */
9
9
  export async function withRetry429(fn, opts = {}) {
10
10
  const maxAttempts = opts.maxAttempts ?? 5;
@@ -14,26 +14,27 @@ export async function withRetry429(fn, opts = {}) {
14
14
  attempt++;
15
15
  try {
16
16
  return await fn();
17
- } catch (e) {
18
- const status = e instanceof RestError ? e.response?.status : undefined;
19
- const retryAfter = e instanceof RestError ? e.response?.headers?.['retry-after'] : undefined;
17
+ } catch (ex) {
18
+ const status = ex instanceof RestError ? ex.response?.status : undefined;
19
+ const retryAfter =
20
+ ex instanceof RestError ? ex.response?.headers?.['retry-after'] : undefined;
20
21
  if (status === 429 && attempt < maxAttempts) {
21
22
  const wait =
22
- retryAfter !== undefined
23
- ? Number.parseInt(String(retryAfter), 10) * 1000 || delayMs
24
- : delayMs;
23
+ retryAfter === undefined
24
+ ? delayMs
25
+ : Number.parseInt(String(retryAfter), 10) * 1000 || delayMs;
25
26
  await sleep(wait);
26
27
  delayMs = Math.min(delayMs * 2, 60_000);
27
28
  continue;
28
29
  }
29
- throw e;
30
+ throw ex;
30
31
  }
31
32
  }
32
33
  }
33
34
 
34
35
  /**
35
36
  * @param {number} ms
36
- * @returns {Promise<void>}
37
+ * @returns {Promise.<void>}
37
38
  */
38
39
  function sleep(ms) {
39
40
  return new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "SFMC Data Loader CLI (mcdata) to export and import Marketing Cloud Data Extension rows using mcdev project config and sfmc-sdk",
5
5
  "author": "Jörn Berkefeld <joern.berkefeld@gmail.com>",
6
6
  "license": "MIT",
@@ -44,6 +44,20 @@
44
44
  "sfmc-sdk": "3.0.3"
45
45
  },
46
46
  "scripts": {
47
- "test": "node --test test/**/*.test.js"
47
+ "test": "node --test test/**/*.test.js",
48
+ "lint": "eslint .",
49
+ "lint:fix": "eslint --fix .",
50
+ "format": "prettier --write .",
51
+ "format:check": "prettier --check ."
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^10.0.1",
55
+ "eslint": "^10.1.0",
56
+ "eslint-config-prettier": "^10.1.8",
57
+ "eslint-plugin-jsdoc": "^62.0.0",
58
+ "eslint-plugin-prettier": "^5.5.0",
59
+ "eslint-plugin-unicorn": "^64.0.0",
60
+ "globals": "^17.4.0",
61
+ "prettier": "^3.8.1"
48
62
  }
49
63
  }