sfmc-dataloader 2.0.1 → 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,9 +20,14 @@ 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() {
27
+ const injected = globalThis.__sfmc_dataloader_version__;
28
+ if (typeof injected === 'string' && injected.length > 0) {
29
+ return injected;
30
+ }
25
31
  const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
26
32
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
27
33
  return typeof pkg.version === 'string' ? pkg.version : '';
@@ -40,9 +46,10 @@ Usage:
40
46
  Options:
41
47
  --version Print version and exit
42
48
  -p, --project <dir> mcdev project root (default: cwd)
43
- --format <csv|tsv|json> File format (default: csv)
49
+ --format <csv|tsv|json> Export file format (default: csv); ignored for imports
44
50
  --json-pretty Pretty-print JSON on export
45
51
  --git Stable filenames: <key>.mcdata.<ext> (no timestamp)
52
+ --debug Write API requests/responses to ./logs/data/*.log
46
53
 
47
54
  Import options:
48
55
  --mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
@@ -57,8 +64,9 @@ Multi-BU options:
57
64
 
58
65
  Notes:
59
66
  Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
60
- 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).
61
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).
62
70
  Cross-BU import stores a download file in each target BU's data directory.
63
71
 
64
72
  Clear data warning:
@@ -69,7 +77,7 @@ Clear data warning:
69
77
 
70
78
  /**
71
79
  * @param {string[]} argv
72
- * @returns {Promise<number>} exit code
80
+ * @returns {Promise.<number>} exit code
73
81
  */
74
82
  export async function main(argv) {
75
83
  let values;
@@ -91,14 +99,15 @@ export async function main(argv) {
91
99
  'clear-before-import': { type: 'boolean', default: false },
92
100
  'i-accept-clear-data-risk': { type: 'boolean', default: false },
93
101
  'json-pretty': { type: 'boolean', default: false },
102
+ debug: { type: 'boolean', default: false },
94
103
  help: { type: 'boolean', short: 'h', default: false },
95
104
  version: { type: 'boolean', default: false },
96
105
  },
97
106
  });
98
107
  values = parsed.values;
99
108
  positionals = parsed.positionals;
100
- } catch (e) {
101
- console.error(e.message);
109
+ } catch (ex) {
110
+ console.error(ex.message);
102
111
  printHelp();
103
112
  return 1;
104
113
  }
@@ -133,6 +142,15 @@ export async function main(argv) {
133
142
  const hasTo = toFlags.length > 0;
134
143
  const hasPositional = !!credBuRaw;
135
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
+
136
154
  // ── export ──────────────────────────────────────────────────────────────
137
155
  if (sub === 'export') {
138
156
  if (hasTo) {
@@ -140,19 +158,23 @@ export async function main(argv) {
140
158
  return 1;
141
159
  }
142
160
 
143
- const des = [].concat(values.de ?? []);
161
+ const des = [values.de ?? []].flat();
144
162
  if (des.length === 0) {
145
163
  console.error('export requires at least one --de <customerKey>');
146
164
  return 1;
147
165
  }
148
166
 
149
167
  if (hasFrom && hasPositional) {
150
- 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
+ );
151
171
  return 1;
152
172
  }
153
173
 
154
174
  if (!hasFrom && !hasPositional) {
155
- 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
+ );
156
178
  printHelp();
157
179
  return 1;
158
180
  }
@@ -161,8 +183,8 @@ export async function main(argv) {
161
183
  let sources;
162
184
  try {
163
185
  sources = fromFlags.map(parseCredBu);
164
- } catch (e) {
165
- console.error(e.message);
186
+ } catch (ex) {
187
+ console.error(ex.message);
166
188
  return 1;
167
189
  }
168
190
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -175,6 +197,7 @@ export async function main(argv) {
175
197
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
176
198
  jsonPretty: values['json-pretty'],
177
199
  useGit,
200
+ logger,
178
201
  });
179
202
  return 0;
180
203
  }
@@ -182,7 +205,7 @@ export async function main(argv) {
182
205
  const { credential, bu } = parseCredBu(credBuRaw);
183
206
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
184
207
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
185
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
208
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
186
209
  for (const deKey of des) {
187
210
  const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
188
211
  projectRoot,
@@ -213,19 +236,23 @@ export async function main(argv) {
213
236
  // ── File-to-multi-BU import: --to + --file (no --from) ─────────────
214
237
  if (hasTo && !hasFrom && values.file?.length > 0) {
215
238
  if (hasPositional) {
216
- 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
+ );
217
242
  return 1;
218
243
  }
219
244
  if (values.de?.length > 0) {
220
- 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
+ );
221
248
  return 1;
222
249
  }
223
250
  const filePaths = values.file;
224
251
  let targets;
225
252
  try {
226
253
  targets = toFlags.map(parseCredBu);
227
- } catch (e) {
228
- console.error(e.message);
254
+ } catch (ex) {
255
+ console.error(ex.message);
229
256
  return 1;
230
257
  }
231
258
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -241,6 +268,7 @@ export async function main(argv) {
241
268
  acceptRiskFlag: acceptRisk,
242
269
  isTTY: process.stdin.isTTY === true,
243
270
  useGit,
271
+ logger,
244
272
  });
245
273
  return 0;
246
274
  }
@@ -248,26 +276,36 @@ export async function main(argv) {
248
276
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
249
277
  if (hasFrom || hasTo) {
250
278
  if (hasPositional) {
251
- 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
+ );
252
282
  return 1;
253
283
  }
254
284
  if (!hasFrom) {
255
- 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
+ );
256
288
  return 1;
257
289
  }
258
290
  if (!hasTo) {
259
- 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
+ );
260
294
  return 1;
261
295
  }
262
296
  if (fromFlags.length > 1) {
263
- 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
+ );
264
300
  return 1;
265
301
  }
266
302
  if (values.file?.length > 0) {
267
- 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
+ );
268
306
  return 1;
269
307
  }
270
- const deKeys = [].concat(values.de ?? []);
308
+ const deKeys = [values.de ?? []].flat();
271
309
  if (deKeys.length === 0) {
272
310
  console.error('Cross-BU import requires at least one --de <customerKey>.');
273
311
  return 1;
@@ -277,8 +315,8 @@ export async function main(argv) {
277
315
  try {
278
316
  sourceParsed = parseCredBu(fromFlags[0]);
279
317
  targets = toFlags.map(parseCredBu);
280
- } catch (e) {
281
- console.error(e.message);
318
+ } catch (ex) {
319
+ console.error(ex.message);
282
320
  return 1;
283
321
  }
284
322
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -296,13 +334,16 @@ export async function main(argv) {
296
334
  acceptRiskFlag: acceptRisk,
297
335
  isTTY: process.stdin.isTTY === true,
298
336
  useGit,
337
+ logger,
299
338
  });
300
339
  return 0;
301
340
  }
302
341
 
303
342
  // ── Single-BU import (original behavior) ────────────────────────────
304
343
  if (!hasPositional) {
305
- 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
+ );
306
347
  printHelp();
307
348
  return 1;
308
349
  }
@@ -310,17 +351,19 @@ export async function main(argv) {
310
351
  const hasDe = values.de?.length > 0;
311
352
  const hasFile = values.file?.length > 0;
312
353
  if (hasDe === hasFile) {
313
- 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
+ );
314
357
  return 1;
315
358
  }
316
359
 
317
360
  const { credential, bu } = parseCredBu(credBuRaw);
318
361
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
319
362
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
320
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
363
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
321
364
 
322
365
  if (hasDe) {
323
- const deKeys = [].concat(values.de ?? []);
366
+ const deKeys = [values.de ?? []].flat();
324
367
  const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
325
368
  if (clear) {
326
369
  await confirmClearBeforeImport({
@@ -333,9 +376,11 @@ export async function main(argv) {
333
376
  }
334
377
  }
335
378
  for (const deKey of deKeys) {
336
- const candidates = await findImportCandidates(dataDir, deKey, fmt);
379
+ const candidates = await findImportCandidates(dataDir, deKey);
337
380
  if (candidates.length === 0) {
338
- 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
+ );
339
384
  return 1;
340
385
  }
341
386
  const filePath =
@@ -343,7 +388,6 @@ export async function main(argv) {
343
388
  const n = await importFromFile(sdk, {
344
389
  filePath,
345
390
  deKey,
346
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
347
391
  mode: /** @type {'upsert'|'insert'} */ (mode),
348
392
  });
349
393
  const rel = projectRelativePosix(projectRoot, filePath);
@@ -353,7 +397,9 @@ export async function main(argv) {
353
397
  }
354
398
 
355
399
  const fileList = values.file ?? [];
356
- const keysFromFiles = fileList.map((fp) => parseExportBasename(path.basename(fp)).customerKey);
400
+ const keysFromFiles = fileList.map(
401
+ (fp) => parseExportBasename(path.basename(fp)).customerKey,
402
+ );
357
403
  if (clear) {
358
404
  await confirmClearBeforeImport({
359
405
  deKeys: keysFromFiles,
@@ -370,7 +416,6 @@ export async function main(argv) {
370
416
  const n = await importFromFile(sdk, {
371
417
  filePath,
372
418
  deKey: customerKey,
373
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
374
419
  mode: /** @type {'upsert'|'insert'} */ (mode),
375
420
  });
376
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.1",
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
  }