sfmc-dataloader 2.0.2 → 2.2.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
@@ -1,6 +1,6 @@
1
1
  # sfmc-dataloader
2
2
 
3
- Command-line tool **`mcdata`** to export and import Salesforce Marketing Cloud Data Extension rows using the same project files as [mcdev](https://github.com/Accenture/sfmc-devtools) (`.mcdevrc.json`, `.mcdev-auth.json`) and [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
3
+ Command-line tool **`mcdata`** to export and import **Salesforce Marketing Cloud Engagement** Data Extension rows using the same project files as [mcdev](https://github.com/Accenture/sfmc-devtools) (`.mcdevrc.json`, `.mcdev-auth.json`) and [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -41,12 +41,12 @@ Creates one file per BU/DE combination using the same `.mcdata.` naming rules.
41
41
  ### Import — single BU
42
42
 
43
43
  ```bash
44
- mcdata import MyCred/MyBU --de MyDE_CustomerKey --format csv --mode upsert
44
+ mcdata import MyCred/MyBU --de MyDE_CustomerKey --mode upsert
45
45
  ```
46
46
 
47
47
  Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path).
48
48
 
49
- Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key.
49
+ Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key. The file format is detected automatically from the file extension (`.csv`, `.tsv`, `.json`).
50
50
 
51
51
  Import from explicit paths (the DE key is taken from the `.mcdata.` basename):
52
52
 
@@ -56,8 +56,8 @@ mcdata import MyCred/MyBU --file ./data/MyCred/MyBU/encoded%2Bkey.mcdata.2026-04
56
56
 
57
57
  #### Upsert vs insert
58
58
 
59
- - **Upsert** follows the platforms usual behaviour: update when a primary key matches, otherwise insert. For Data Extensions **without** a primary key, upsert may not behave as expected; prefer **`--mode insert`** for those.
60
- - **Insert** always adds new rows. Running import twice with insert can create **duplicate** rows if the same file is applied again—use upsert when keys are defined and you need idempotent runs.
59
+ - **Upsert** follows the platform's usual behaviour: update when a primary key matches, otherwise insert. For Data Extensions **without** a primary key, upsert **will fail**; use **`--mode insert`** for those.
60
+ - **Insert** always adds new rows. Running import twice with insert can create **duplicate** rows if the same file is applied again—use upsert when primary keys are defined and you need to ensure repeated runs always have the same outcome.
61
61
 
62
62
  ### Import — one source BU into multiple target BUs (API mode)
63
63
 
@@ -67,7 +67,7 @@ Use `--from` (one source) and `--to` (repeatable targets) for a cross-BU import
67
67
  mcdata import --from MyCred/Dev --to MyCred/QA --to MyCred/Prod --de Contact_DE
68
68
  ```
69
69
 
70
- Before the import starts you will be offered the option to export the current data from each target BU as a timestamped backup. A download file is also written to each target BU's data directory so there is a traceable record of exactly what was imported.
70
+ An optional pre-import backup exports current target BU data as **timestamped** files (backup filenames always include the timestamp, regardless of `--git`). Use `--backup-before-import` to run the backup without a prompt (CI-safe), or `--no-backup-before-import` to skip it entirely. When neither flag is provided the CLI prompts interactively (TTY only). A snapshot file is also written to each target BU's data directory as a record of what was imported.
71
71
 
72
72
  ### Import — local export files into multiple target BUs (file mode)
73
73
 
@@ -80,6 +80,20 @@ mcdata import --to MyCred/QA --to MyCred/Prod \
80
80
 
81
81
  Multiple files can be supplied to push several DEs in one command. Pass **`--git`** on export if you rely on stable `*.mcdata.<ext>` names for snapshots in this flow.
82
82
 
83
+ ### Backup target DE before import
84
+
85
+ Use `--backup-before-import` on any import command (single-BU or cross-BU) to export a timestamped snapshot of the current target DE rows before the import runs. The backup filename always includes a timestamp regardless of whether `--git` is set.
86
+
87
+ ```bash
88
+ mcdata import MyCred/MyBU --de MyKey --backup-before-import
89
+ ```
90
+
91
+ In CI, combine with `--no-backup-before-import` to suppress any TTY prompt:
92
+
93
+ ```bash
94
+ mcdata import MyCred/MyBU --de MyKey --no-backup-before-import
95
+ ```
96
+
83
97
  ### Clear all rows before import
84
98
 
85
99
  **Dangerous:** removes every row in the target Data Extension(s) before uploading.
@@ -101,16 +115,18 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
101
115
 
102
116
  ## Options
103
117
 
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 |
118
+ | Option | Description |
119
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------- |
120
+ | `-p, --project` | Project root (default: cwd) |
121
+ | `--format` | `csv` (default), `tsv`, or `json` — **export only**; import format is detected from the file extension |
122
+ | `--git` | Stable export filenames: `<key>.mcdata.<ext>` (no timestamp segment) |
123
+ | `--mode` | `upsert` (default) or `insert` — async bulk REST API only |
124
+ | `--from <cred>/<bu>` | Export: source BU (repeatable). Import API mode: single source BU (use with `--to` and `--de`) |
125
+ | `--to <cred>/<bu>` | Import: target BU (repeatable). API mode: use with `--from`/`--de`. File mode: use with `--file` (no `--from` needed) |
126
+ | `--backup-before-import` | Export target DE data as a timestamped backup before import (no prompt; always timestamped) |
127
+ | `--no-backup-before-import` | Skip the backup prompt even in interactive (TTY) sessions |
128
+ | `--clear-before-import` | SOAP `ClearData` before REST import |
129
+ | `--i-accept-clear-data-risk` | Non-interactive consent for clear |
114
130
 
115
131
  Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
116
132
 
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,12 +46,15 @@ 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)
56
+ --backup-before-import Export target DE data as a timestamped backup before import (no prompt)
57
+ --no-backup-before-import Skip the backup prompt even in interactive (TTY) sessions
53
58
  --clear-before-import SOAP ClearData before import (destructive; see below)
54
59
  --i-accept-clear-data-risk Non-interactive acknowledgement for --clear-before-import
55
60
 
@@ -61,8 +66,9 @@ Multi-BU options:
61
66
 
62
67
  Notes:
63
68
  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).
69
+ Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
65
70
  Import with --file parses the DE key from the basename (.mcdata. format).
71
+ Import format is auto-detected from file extension (.csv, .tsv, .json).
66
72
  Cross-BU import stores a download file in each target BU's data directory.
67
73
 
68
74
  Clear data warning:
@@ -73,7 +79,7 @@ Clear data warning:
73
79
 
74
80
  /**
75
81
  * @param {string[]} argv
76
- * @returns {Promise<number>} exit code
82
+ * @returns {Promise.<number>} exit code
77
83
  */
78
84
  export async function main(argv) {
79
85
  let values;
@@ -92,17 +98,20 @@ export async function main(argv) {
92
98
  to: { type: 'string', multiple: true },
93
99
  git: { type: 'boolean', default: false },
94
100
  mode: { type: 'string' },
101
+ 'backup-before-import': { type: 'boolean', default: false },
102
+ 'no-backup-before-import': { type: 'boolean', default: false },
95
103
  'clear-before-import': { type: 'boolean', default: false },
96
104
  'i-accept-clear-data-risk': { type: 'boolean', default: false },
97
105
  'json-pretty': { type: 'boolean', default: false },
106
+ debug: { type: 'boolean', default: false },
98
107
  help: { type: 'boolean', short: 'h', default: false },
99
108
  version: { type: 'boolean', default: false },
100
109
  },
101
110
  });
102
111
  values = parsed.values;
103
112
  positionals = parsed.positionals;
104
- } catch (e) {
105
- console.error(e.message);
113
+ } catch (ex) {
114
+ console.error(ex.message);
106
115
  printHelp();
107
116
  return 1;
108
117
  }
@@ -130,6 +139,12 @@ export async function main(argv) {
130
139
  return 1;
131
140
  }
132
141
 
142
+ const backupBeforeImport = values['backup-before-import']
143
+ ? true
144
+ : values['no-backup-before-import']
145
+ ? false
146
+ : undefined;
147
+
133
148
  const useGit = values.git === true;
134
149
  const fromFlags = values.from ?? [];
135
150
  const toFlags = values.to ?? [];
@@ -137,6 +152,15 @@ export async function main(argv) {
137
152
  const hasTo = toFlags.length > 0;
138
153
  const hasPositional = !!credBuRaw;
139
154
 
155
+ // Initialize debug logger if --debug flag is set
156
+ /** @type {import('./debug-logger.mjs').DebugLogger|null} */
157
+ const logger = values.debug
158
+ ? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
159
+ : null;
160
+ if (logger) {
161
+ console.error(`Debug log: ${projectRelativePosix(projectRoot, logger.logPath)}`);
162
+ }
163
+
140
164
  // ── export ──────────────────────────────────────────────────────────────
141
165
  if (sub === 'export') {
142
166
  if (hasTo) {
@@ -144,19 +168,23 @@ export async function main(argv) {
144
168
  return 1;
145
169
  }
146
170
 
147
- const des = [].concat(values.de ?? []);
171
+ const des = [values.de ?? []].flat();
148
172
  if (des.length === 0) {
149
173
  console.error('export requires at least one --de <customerKey>');
150
174
  return 1;
151
175
  }
152
176
 
153
177
  if (hasFrom && hasPositional) {
154
- console.error('Cannot mix a positional <credential>/<bu> with --from. Use one or the other.');
178
+ console.error(
179
+ 'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
180
+ );
155
181
  return 1;
156
182
  }
157
183
 
158
184
  if (!hasFrom && !hasPositional) {
159
- console.error('export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.');
185
+ console.error(
186
+ 'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
187
+ );
160
188
  printHelp();
161
189
  return 1;
162
190
  }
@@ -165,8 +193,8 @@ export async function main(argv) {
165
193
  let sources;
166
194
  try {
167
195
  sources = fromFlags.map(parseCredBu);
168
- } catch (e) {
169
- console.error(e.message);
196
+ } catch (ex) {
197
+ console.error(ex.message);
170
198
  return 1;
171
199
  }
172
200
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -179,6 +207,7 @@ export async function main(argv) {
179
207
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
180
208
  jsonPretty: values['json-pretty'],
181
209
  useGit,
210
+ logger,
182
211
  });
183
212
  return 0;
184
213
  }
@@ -186,7 +215,7 @@ export async function main(argv) {
186
215
  const { credential, bu } = parseCredBu(credBuRaw);
187
216
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
188
217
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
189
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
218
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
190
219
  for (const deKey of des) {
191
220
  const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
192
221
  projectRoot,
@@ -217,19 +246,23 @@ export async function main(argv) {
217
246
  // ── File-to-multi-BU import: --to + --file (no --from) ─────────────
218
247
  if (hasTo && !hasFrom && values.file?.length > 0) {
219
248
  if (hasPositional) {
220
- console.error('Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.');
249
+ console.error(
250
+ 'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
251
+ );
221
252
  return 1;
222
253
  }
223
254
  if (values.de?.length > 0) {
224
- console.error('Cannot mix --de with --file in multi-target import. Use --file only.');
255
+ console.error(
256
+ 'Cannot mix --de with --file in multi-target import. Use --file only.',
257
+ );
225
258
  return 1;
226
259
  }
227
260
  const filePaths = values.file;
228
261
  let targets;
229
262
  try {
230
263
  targets = toFlags.map(parseCredBu);
231
- } catch (e) {
232
- console.error(e.message);
264
+ } catch (ex) {
265
+ console.error(ex.message);
233
266
  return 1;
234
267
  }
235
268
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -241,10 +274,12 @@ export async function main(argv) {
241
274
  targets,
242
275
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
243
276
  mode: /** @type {'upsert'|'insert'} */ (mode),
277
+ backupBeforeImport,
244
278
  clearBeforeImport: clear,
245
279
  acceptRiskFlag: acceptRisk,
246
280
  isTTY: process.stdin.isTTY === true,
247
281
  useGit,
282
+ logger,
248
283
  });
249
284
  return 0;
250
285
  }
@@ -252,26 +287,36 @@ export async function main(argv) {
252
287
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
253
288
  if (hasFrom || hasTo) {
254
289
  if (hasPositional) {
255
- console.error('Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.');
290
+ console.error(
291
+ 'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
292
+ );
256
293
  return 1;
257
294
  }
258
295
  if (!hasFrom) {
259
- console.error('--to requires --from <cred>/<bu> to specify the source Business Unit.');
296
+ console.error(
297
+ '--to requires --from <cred>/<bu> to specify the source Business Unit.',
298
+ );
260
299
  return 1;
261
300
  }
262
301
  if (!hasTo) {
263
- console.error('--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).');
302
+ console.error(
303
+ '--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
304
+ );
264
305
  return 1;
265
306
  }
266
307
  if (fromFlags.length > 1) {
267
- console.error('import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).');
308
+ console.error(
309
+ 'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
310
+ );
268
311
  return 1;
269
312
  }
270
313
  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).');
314
+ console.error(
315
+ '--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).',
316
+ );
272
317
  return 1;
273
318
  }
274
- const deKeys = [].concat(values.de ?? []);
319
+ const deKeys = [values.de ?? []].flat();
275
320
  if (deKeys.length === 0) {
276
321
  console.error('Cross-BU import requires at least one --de <customerKey>.');
277
322
  return 1;
@@ -281,8 +326,8 @@ export async function main(argv) {
281
326
  try {
282
327
  sourceParsed = parseCredBu(fromFlags[0]);
283
328
  targets = toFlags.map(parseCredBu);
284
- } catch (e) {
285
- console.error(e.message);
329
+ } catch (ex) {
330
+ console.error(ex.message);
286
331
  return 1;
287
332
  }
288
333
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
@@ -296,17 +341,21 @@ export async function main(argv) {
296
341
  deKeys,
297
342
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
298
343
  mode: /** @type {'upsert'|'insert'} */ (mode),
344
+ backupBeforeImport,
299
345
  clearBeforeImport: clear,
300
346
  acceptRiskFlag: acceptRisk,
301
347
  isTTY: process.stdin.isTTY === true,
302
348
  useGit,
349
+ logger,
303
350
  });
304
351
  return 0;
305
352
  }
306
353
 
307
354
  // ── Single-BU import (original behavior) ────────────────────────────
308
355
  if (!hasPositional) {
309
- console.error('import requires either a positional <credential>/<bu> or --from/--to flags.');
356
+ console.error(
357
+ 'import requires either a positional <credential>/<bu> or --from/--to flags.',
358
+ );
310
359
  printHelp();
311
360
  return 1;
312
361
  }
@@ -314,18 +363,35 @@ export async function main(argv) {
314
363
  const hasDe = values.de?.length > 0;
315
364
  const hasFile = values.file?.length > 0;
316
365
  if (hasDe === hasFile) {
317
- console.error('import requires exactly one of: repeated --de <key> OR repeated --file <path>');
366
+ console.error(
367
+ 'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
368
+ );
318
369
  return 1;
319
370
  }
320
371
 
321
372
  const { credential, bu } = parseCredBu(credBuRaw);
322
373
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
323
374
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
324
- const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
375
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
325
376
 
326
377
  if (hasDe) {
327
- const deKeys = [].concat(values.de ?? []);
378
+ const deKeys = [values.de ?? []].flat();
328
379
  const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
380
+ if (backupBeforeImport === true) {
381
+ for (const deKey of deKeys) {
382
+ const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
383
+ projectRoot,
384
+ credentialName: credential,
385
+ buName: bu,
386
+ deKey,
387
+ format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
388
+ useGit: false,
389
+ });
390
+ console.error(
391
+ `Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
392
+ );
393
+ }
394
+ }
329
395
  if (clear) {
330
396
  await confirmClearBeforeImport({
331
397
  deKeys,
@@ -337,9 +403,11 @@ export async function main(argv) {
337
403
  }
338
404
  }
339
405
  for (const deKey of deKeys) {
340
- const candidates = await findImportCandidates(dataDir, deKey, fmt);
406
+ const candidates = await findImportCandidates(dataDir, deKey);
341
407
  if (candidates.length === 0) {
342
- console.error(`No ${fmt} file found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`);
408
+ console.error(
409
+ `No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
410
+ );
343
411
  return 1;
344
412
  }
345
413
  const filePath =
@@ -347,7 +415,6 @@ export async function main(argv) {
347
415
  const n = await importFromFile(sdk, {
348
416
  filePath,
349
417
  deKey,
350
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
351
418
  mode: /** @type {'upsert'|'insert'} */ (mode),
352
419
  });
353
420
  const rel = projectRelativePosix(projectRoot, filePath);
@@ -357,7 +424,24 @@ export async function main(argv) {
357
424
  }
358
425
 
359
426
  const fileList = values.file ?? [];
360
- const keysFromFiles = fileList.map((fp) => parseExportBasename(path.basename(fp)).customerKey);
427
+ const keysFromFiles = fileList.map(
428
+ (fp) => parseExportBasename(path.basename(fp)).customerKey,
429
+ );
430
+ if (backupBeforeImport === true) {
431
+ for (const deKey of keysFromFiles) {
432
+ const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
433
+ projectRoot,
434
+ credentialName: credential,
435
+ buName: bu,
436
+ deKey,
437
+ format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
438
+ useGit: false,
439
+ });
440
+ console.error(
441
+ `Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
442
+ );
443
+ }
444
+ }
361
445
  if (clear) {
362
446
  await confirmClearBeforeImport({
363
447
  deKeys: keysFromFiles,
@@ -374,7 +458,6 @@ export async function main(argv) {
374
458
  const n = await importFromFile(sdk, {
375
459
  filePath,
376
460
  deKey: customerKey,
377
- format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
378
461
  mode: /** @type {'upsert'|'insert'} */ (mode),
379
462
  });
380
463
  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,19 +67,21 @@ 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
74
75
  * @param {'upsert'|'insert'} params.mode
76
+ * @param {boolean} [params.backupBeforeImport] - true=always backup, false=never backup, undefined=TTY prompt
75
77
  * @param {boolean} params.clearBeforeImport
76
78
  * @param {boolean} params.acceptRiskFlag
77
79
  * @param {boolean} params.isTTY
78
- * @param {boolean} [params.useGit] - stable snapshot basename (no timestamp)
79
- * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
80
+ * @param {boolean} [params.useGit] - accepted for API compatibility but ignored; snapshot files always use a timestamped name
81
+ * @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
82
+ * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
80
83
  * @param {NodeJS.WritableStream} [params.stdout] Override for testing
81
- * @returns {Promise<void>}
84
+ * @returns {Promise.<void>}
82
85
  */
83
86
  export async function crossBuImport(params) {
84
87
  const {
@@ -88,10 +91,11 @@ export async function crossBuImport(params) {
88
91
  targets,
89
92
  format,
90
93
  mode,
94
+ backupBeforeImport,
91
95
  clearBeforeImport,
92
96
  acceptRiskFlag,
93
97
  isTTY,
94
- useGit = false,
98
+ logger = null,
95
99
  } = params;
96
100
  const stdin = params.stdin;
97
101
  const stdout = params.stdout;
@@ -123,30 +127,38 @@ export async function crossBuImport(params) {
123
127
  let srcSdk = null;
124
128
  if (!isFileBased) {
125
129
  const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(
126
- mcdevrc, mcdevAuth, params.sourceCred, params.sourceBu
130
+ mcdevrc,
131
+ mcdevAuth,
132
+ params.sourceCred,
133
+ params.sourceBu,
127
134
  );
128
- srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), { requestAttempts: 3 });
135
+ srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), buildSdkOptions(logger));
129
136
  }
130
137
 
131
138
  // Optional pre-import backup of target BU data
132
- if (isTTY) {
133
- const doBackup = await offerPreExportBackup({ targets, deKeys, stdin, stdout });
134
- if (doBackup) {
135
- 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 });
138
- for (const deKey of deKeys) {
139
- const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
140
- projectRoot,
141
- credentialName: credential,
142
- buName: bu,
143
- deKey,
144
- format,
145
- useGit: false,
146
- });
147
- const rel = projectRelativePosix(projectRoot, outPath);
148
- console.error(`Backup export: ${rel} (${rowCount} rows)`);
149
- }
139
+ const shouldBackup =
140
+ backupBeforeImport === true
141
+ ? true
142
+ : backupBeforeImport === false
143
+ ? false
144
+ : isTTY
145
+ ? await offerPreExportBackup({ targets, deKeys, stdin, stdout })
146
+ : false;
147
+ if (shouldBackup) {
148
+ for (const { credential, bu } of targets) {
149
+ const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
150
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
151
+ for (const deKey of deKeys) {
152
+ const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
153
+ projectRoot,
154
+ credentialName: credential,
155
+ buName: bu,
156
+ deKey,
157
+ format,
158
+ useGit: false,
159
+ });
160
+ const rel = projectRelativePosix(projectRoot, outPath);
161
+ console.error(`Backup export: ${rel} (${rowCount} rows)`);
150
162
  }
151
163
  }
152
164
  }
@@ -158,28 +170,43 @@ export async function crossBuImport(params) {
158
170
 
159
171
  // Load rows once per DE then fan out to every target
160
172
  for (const deKey of deKeys) {
161
- const rows = isFileBased
162
- ? await readRowsFromFile(fileByDeKey.get(deKey), format)
163
- : await fetchAllRowObjects(srcSdk, deKey);
173
+ let rows;
174
+ if (isFileBased) {
175
+ const filePath = fileByDeKey.get(deKey);
176
+ const detectedFormat = formatFromExtension(filePath);
177
+ if (!detectedFormat) {
178
+ throw new Error(
179
+ `Cannot determine format for file: ${filePath}. Use .csv, .tsv, or .json extension.`,
180
+ );
181
+ }
182
+ rows = await readRowsFromFile(filePath, detectedFormat);
183
+ } else {
184
+ rows = await fetchAllRowObjects(srcSdk, deKey);
185
+ }
164
186
 
165
187
  // Clear targets before import (rows already confirmed above)
166
188
  if (clearBeforeImport) {
167
189
  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 });
190
+ const { mid, authCred } = resolveCredentialAndMid(
191
+ mcdevrc,
192
+ mcdevAuth,
193
+ credential,
194
+ bu,
195
+ );
196
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
170
197
  await clearDataExtensionRows(tgtSdk.soap, deKey);
171
198
  }
172
199
  }
173
200
 
174
201
  for (const { credential, bu } of targets) {
175
202
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
176
- const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
203
+ const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
177
204
 
178
205
  // Write a snapshot file in the target BU's data directory.
179
206
  const dir = dataDirectoryForBu(projectRoot, credential, bu);
180
207
  await fs.mkdir(dir, { recursive: true });
181
208
  const ts = filesystemSafeTimestamp();
182
- const basename = buildExportBasename(deKey, ts, format, useGit);
209
+ const basename = buildExportBasename(deKey, ts, format, false);
183
210
  const snapshotPath = path.join(dir, basename);
184
211
  await fs.writeFile(snapshotPath, serializeRows(rows, format, false), 'utf8');
185
212
  const snapRel = projectRelativePosix(projectRoot, snapshotPath);
@@ -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.2.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
  }