sfmc-dataloader 2.2.0 → 2.4.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
@@ -44,7 +44,7 @@ Creates one file per BU/DE combination using the same `.mcdata.` naming rules.
44
44
  mcdata import MyCred/MyBU --de MyDE_CustomerKey --mode upsert
45
45
  ```
46
46
 
47
- Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path).
47
+ Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path). After uploading, `mcdata` polls the async status endpoint every 5 seconds until the job reaches `Complete` or `Error`. On `Error`, per-row error messages are printed to the log so you can see exactly which records failed. The process exits with code 1 if any import job fails, which also surfaces as an error notification in the VS Code extension.
48
48
 
49
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
 
@@ -0,0 +1,89 @@
1
+ import { asyncRequestResultsPath, asyncRequestStatusPath } from './import-routes.mjs';
2
+
3
+ const POLL_INTERVAL_MS = 5000;
4
+ const PENDING_STATUSES = new Set(['Pending', 'Executing']);
5
+
6
+ /**
7
+ * @param {number} ms
8
+ * @returns {Promise.<void>}
9
+ */
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+
14
+ /**
15
+ * Polls the async import status API for each requestId until the job reaches
16
+ * a terminal state ("Complete" or "Error"). On error, fetches per-row results
17
+ * and logs each item message so the user can see what failed.
18
+ *
19
+ * @param {{ rest: { get: Function } }} sdk
20
+ * @param {(string|null|undefined)[]} requestIds - one per imported chunk
21
+ * @returns {Promise.<boolean>} true if at least one chunk job returned "Error"
22
+ */
23
+ export async function pollAsyncImportCompletion(sdk, requestIds) {
24
+ let hasError = false;
25
+
26
+ for (const requestId of requestIds) {
27
+ if (!requestId) {
28
+ console.error('Async import: no requestId returned for chunk — skipping status check.');
29
+ continue;
30
+ }
31
+
32
+ console.error(`Waiting for async import to complete (requestId: ${requestId})…`);
33
+ await sleep(POLL_INTERVAL_MS);
34
+
35
+ let requestStatus;
36
+ do {
37
+ let statusResult;
38
+ try {
39
+ statusResult = await sdk.rest.get(asyncRequestStatusPath(requestId));
40
+ } catch (ex) {
41
+ console.error(
42
+ `Async import: status check failed for requestId ${requestId}: ${ex.message}`,
43
+ );
44
+ break;
45
+ }
46
+
47
+ requestStatus = statusResult?.status?.requestStatus;
48
+
49
+ if (PENDING_STATUSES.has(requestStatus)) {
50
+ console.error(
51
+ `Async import still in progress (requestId: ${requestId}) — retrying in 5 s…`,
52
+ );
53
+ await sleep(POLL_INTERVAL_MS);
54
+ }
55
+ } while (PENDING_STATUSES.has(requestStatus));
56
+
57
+ if (requestStatus === 'Complete') {
58
+ console.error(`Async import completed successfully (requestId: ${requestId}).`);
59
+ } else if (requestStatus === 'Error') {
60
+ console.error(`Async import job failed (requestId: ${requestId}).`);
61
+ hasError = true;
62
+
63
+ let resultsResult;
64
+ try {
65
+ resultsResult = await sdk.rest.get(asyncRequestResultsPath(requestId));
66
+ } catch (ex) {
67
+ console.error(
68
+ `Async import: could not retrieve error details for requestId ${requestId}: ${ex.message}`,
69
+ );
70
+ continue;
71
+ }
72
+
73
+ const items = resultsResult?.items ?? [];
74
+ if (items.length === 0) {
75
+ console.error('Async import: no item-level error details returned by API.');
76
+ } else {
77
+ for (const item of items) {
78
+ console.error(`Import error: ${item.message}`);
79
+ }
80
+ }
81
+ } else if (requestStatus !== undefined) {
82
+ console.error(
83
+ `Async import: unexpected status "${requestStatus}" for requestId ${requestId}.`,
84
+ );
85
+ }
86
+ }
87
+
88
+ return hasError;
89
+ }
package/lib/cli.mjs CHANGED
@@ -16,8 +16,10 @@ import { exportDataExtensionToFile } from './export-de.mjs';
16
16
  import { findImportCandidates, pickLatestByMtime } from './file-resolve.mjs';
17
17
  import { parseExportBasename } from './filename.mjs';
18
18
  import { importFromFile } from './import-de.mjs';
19
+ import { pollAsyncImportCompletion } from './async-status.mjs';
19
20
  import { clearDataExtensionRows } from './clear-de.mjs';
20
21
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
22
+ import { getDeRowCount } from './row-count.mjs';
21
23
  import { multiBuExport } from './multi-bu-export.mjs';
22
24
  import { crossBuImport } from './cross-bu-import.mjs';
23
25
  import { initDebugLogger } from './debug-logger.mjs';
@@ -266,7 +268,7 @@ export async function main(argv) {
266
268
  return 1;
267
269
  }
268
270
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
269
- await crossBuImport({
271
+ const crossBuHadError = await crossBuImport({
270
272
  projectRoot,
271
273
  mcdevrc,
272
274
  mcdevAuth,
@@ -281,7 +283,7 @@ export async function main(argv) {
281
283
  useGit,
282
284
  logger,
283
285
  });
284
- return 0;
286
+ return crossBuHadError ? 1 : 0;
285
287
  }
286
288
 
287
289
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
@@ -331,7 +333,7 @@ export async function main(argv) {
331
333
  return 1;
332
334
  }
333
335
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
334
- await crossBuImport({
336
+ const crossBuApiHadError = await crossBuImport({
335
337
  projectRoot,
336
338
  mcdevrc,
337
339
  mcdevAuth,
@@ -348,7 +350,7 @@ export async function main(argv) {
348
350
  useGit,
349
351
  logger,
350
352
  });
351
- return 0;
353
+ return crossBuApiHadError ? 1 : 0;
352
354
  }
353
355
 
354
356
  // ── Single-BU import (original behavior) ────────────────────────────
@@ -398,10 +400,8 @@ export async function main(argv) {
398
400
  acceptRiskFlag: acceptRisk,
399
401
  isTTY: process.stdin.isTTY === true,
400
402
  });
401
- for (const deKey of deKeys) {
402
- await clearDataExtensionRows(sdk.soap, deKey);
403
- }
404
403
  }
404
+ let anyError = false;
405
405
  for (const deKey of deKeys) {
406
406
  const candidates = await findImportCandidates(dataDir, deKey);
407
407
  if (candidates.length === 0) {
@@ -412,15 +412,54 @@ export async function main(argv) {
412
412
  }
413
413
  const filePath =
414
414
  candidates.length === 1 ? candidates[0] : await pickLatestByMtime(candidates);
415
- const n = await importFromFile(sdk, {
415
+
416
+ const countBefore = await getDeRowCount(sdk, deKey);
417
+ console.error(
418
+ `Row count before import: ${countBefore ?? '(unavailable)'} (DE "${deKey}")`,
419
+ );
420
+
421
+ if (clear) {
422
+ if (countBefore === 0) {
423
+ console.error(
424
+ `Skipping clear-data for DE "${deKey}" — DE is already empty.`,
425
+ );
426
+ } else {
427
+ await clearDataExtensionRows(sdk.soap, deKey);
428
+ console.warn(`Cleared data: DE "${deKey}"`);
429
+ }
430
+ }
431
+
432
+ const { count: n, requestIds } = await importFromFile(sdk, {
416
433
  filePath,
417
434
  deKey,
418
435
  mode: /** @type {'upsert'|'insert'} */ (mode),
419
436
  });
420
437
  const rel = projectRelativePosix(projectRoot, filePath);
421
438
  console.error(`Imported: ${rel} (${n} rows) -> DE ${deKey}`);
439
+
440
+ const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
441
+ if (importHadError) {
442
+ anyError = true;
443
+ }
444
+
445
+ const countAfter = await getDeRowCount(sdk, deKey);
446
+ console.error(
447
+ `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${deKey}")`,
448
+ );
449
+ if (countAfter === null) {
450
+ console.error(`Could not verify import result for DE "${deKey}".`);
451
+ } else if (countBefore !== null) {
452
+ // Insert: expect countBefore + n; upsert on empty: same; upsert on non-empty: expect >= n
453
+ const expected =
454
+ mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
455
+ if (countAfter < expected) {
456
+ console.error(
457
+ `Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
458
+ );
459
+ }
460
+ }
422
461
  }
423
- return 0;
462
+ return anyError ? 1 : 0;
424
463
  }
425
464
 
426
465
  const fileList = values.file ?? [];
@@ -448,22 +487,58 @@ export async function main(argv) {
448
487
  acceptRiskFlag: acceptRisk,
449
488
  isTTY: process.stdin.isTTY === true,
450
489
  });
451
- for (const deKey of keysFromFiles) {
452
- await clearDataExtensionRows(sdk.soap, deKey);
453
- }
454
490
  }
491
+ let anyFileError = false;
455
492
  for (const filePath of fileList) {
456
493
  const base = path.basename(filePath);
457
494
  const { customerKey } = parseExportBasename(base);
458
- const n = await importFromFile(sdk, {
495
+
496
+ const countBefore = await getDeRowCount(sdk, customerKey);
497
+ console.error(
498
+ `Row count before import: ${countBefore ?? '(unavailable)'} (DE "${customerKey}")`,
499
+ );
500
+
501
+ if (clear) {
502
+ if (countBefore === 0) {
503
+ console.error(
504
+ `Skipping clear-data for DE "${customerKey}" — DE is already empty.`,
505
+ );
506
+ } else {
507
+ await clearDataExtensionRows(sdk.soap, customerKey);
508
+ console.warn(`Cleared data: DE "${customerKey}"`);
509
+ }
510
+ }
511
+
512
+ const { count: n, requestIds } = await importFromFile(sdk, {
459
513
  filePath,
460
514
  deKey: customerKey,
461
515
  mode: /** @type {'upsert'|'insert'} */ (mode),
462
516
  });
463
517
  const rel = projectRelativePosix(projectRoot, filePath);
464
518
  console.error(`Imported: ${rel} (${n} rows)`);
519
+
520
+ const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
521
+ if (importHadError) {
522
+ anyFileError = true;
523
+ }
524
+
525
+ const countAfter = await getDeRowCount(sdk, customerKey);
526
+ console.error(
527
+ `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${customerKey}")`,
528
+ );
529
+ if (countAfter === null) {
530
+ console.error(`Could not verify import result for DE "${customerKey}".`);
531
+ } else {
532
+ const expected =
533
+ mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
534
+ if (countAfter < expected) {
535
+ console.error(
536
+ `Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
537
+ );
538
+ }
539
+ }
465
540
  }
466
- return 0;
541
+ return anyFileError ? 1 : 0;
467
542
  }
468
543
 
469
544
  console.error(`Unknown command: ${sub}`);
@@ -7,11 +7,13 @@ import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './
7
7
  import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
8
8
  import { formatFromExtension } from './file-resolve.mjs';
9
9
  import { importRowsForDe } from './import-de.mjs';
10
+ import { pollAsyncImportCompletion } from './async-status.mjs';
10
11
  import { readRowsFromFile } from './read-rows.mjs';
11
12
  import { clearDataExtensionRows } from './clear-de.mjs';
12
13
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
13
14
  import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
14
15
  import { buildExportBasename, filesystemSafeTimestamp, parseExportBasename } from './filename.mjs';
16
+ import { getDeRowCount } from './row-count.mjs';
15
17
 
16
18
  /**
17
19
  * @typedef {{ credential: string, bu: string }} CredBuTarget
@@ -81,7 +83,7 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
81
83
  * @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
82
84
  * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
83
85
  * @param {NodeJS.WritableStream} [params.stdout] Override for testing
84
- * @returns {Promise.<void>}
86
+ * @returns {Promise.<boolean>} true if at least one import job returned an error
85
87
  */
86
88
  export async function crossBuImport(params) {
87
89
  const {
@@ -168,6 +170,8 @@ export async function crossBuImport(params) {
168
170
  await confirmClearBeforeImport({ deKeys, targets, acceptRiskFlag, isTTY, stdin, stdout });
169
171
  }
170
172
 
173
+ let hasError = false;
174
+
171
175
  // Load rows once per DE then fan out to every target
172
176
  for (const deKey of deKeys) {
173
177
  let rows;
@@ -184,24 +188,27 @@ export async function crossBuImport(params) {
184
188
  rows = await fetchAllRowObjects(srcSdk, deKey);
185
189
  }
186
190
 
187
- // Clear targets before import (rows already confirmed above)
188
- if (clearBeforeImport) {
189
- for (const { credential, bu } of targets) {
190
- const { mid, authCred } = resolveCredentialAndMid(
191
- mcdevrc,
192
- mcdevAuth,
193
- credential,
194
- bu,
195
- );
196
- const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
197
- await clearDataExtensionRows(tgtSdk.soap, deKey);
198
- }
199
- }
200
-
201
191
  for (const { credential, bu } of targets) {
202
192
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
203
193
  const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
204
194
 
195
+ const countBefore = await getDeRowCount(tgtSdk, deKey);
196
+ console.error(
197
+ `Row count before import: ${countBefore ?? '(unavailable)'} (${credential}/${bu} DE "${deKey}")`,
198
+ );
199
+
200
+ // Clear target before import (already confirmed above); skip if DE is empty
201
+ if (clearBeforeImport) {
202
+ if (countBefore === 0) {
203
+ console.error(
204
+ `Skipping clear-data for ${credential}/${bu} DE "${deKey}" — DE is already empty.`,
205
+ );
206
+ } else {
207
+ await clearDataExtensionRows(tgtSdk.soap, deKey);
208
+ console.warn(`Cleared data: ${credential}/${bu} DE "${deKey}"`);
209
+ }
210
+ }
211
+
205
212
  // Write a snapshot file in the target BU's data directory.
206
213
  const dir = dataDirectoryForBu(projectRoot, credential, bu);
207
214
  await fs.mkdir(dir, { recursive: true });
@@ -212,8 +219,39 @@ export async function crossBuImport(params) {
212
219
  const snapRel = projectRelativePosix(projectRoot, snapshotPath);
213
220
  console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
214
221
 
215
- const imported = await importRowsForDe(tgtSdk, { deKey, rows, mode });
222
+ const { count: imported, requestIds } = await importRowsForDe(tgtSdk, {
223
+ deKey,
224
+ rows,
225
+ mode,
226
+ });
216
227
  console.error(`Imported: ${credential}/${bu} DE ${deKey} (${imported} rows)`);
228
+
229
+ const importHadError = await pollAsyncImportCompletion(tgtSdk, requestIds);
230
+ if (importHadError) {
231
+ hasError = true;
232
+ }
233
+
234
+ const countAfter = await getDeRowCount(tgtSdk, deKey);
235
+ console.error(
236
+ `Row count after import: ${countAfter ?? '(unavailable)'} (${credential}/${bu} DE "${deKey}")`,
237
+ );
238
+ if (countAfter === null) {
239
+ console.error(
240
+ `Could not verify import result for ${credential}/${bu} DE "${deKey}".`,
241
+ );
242
+ } else {
243
+ const expected =
244
+ mode === 'insert' || countBefore === 0
245
+ ? (countBefore ?? 0) + imported
246
+ : imported;
247
+ if (countAfter < expected) {
248
+ console.error(
249
+ `Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
250
+ );
251
+ }
252
+ }
217
253
  }
218
254
  }
255
+
256
+ return hasError;
219
257
  }
package/lib/import-de.mjs CHANGED
@@ -10,20 +10,22 @@ import { readRowsFromFile } from './read-rows.mjs';
10
10
  * @param {string} params.deKey
11
11
  * @param {object[]} params.rows
12
12
  * @param {'upsert'|'insert'} params.mode
13
- * @returns {Promise.<number>} number of rows imported
13
+ * @returns {Promise.<{ count: number, requestIds: (string|null)[] }>}
14
14
  */
15
15
  export async function importRowsForDe(sdk, params) {
16
16
  const { deKey, rows, mode } = params;
17
17
  const route = resolveImportRoute(mode);
18
18
  const chunks = chunkItemsForPayload(rows);
19
+ const requestIds = [];
19
20
  for (const chunk of chunks) {
20
21
  const p = route.path(deKey);
21
22
  const body = { items: chunk };
22
- await withRetry429(() =>
23
+ const resp = await withRetry429(() =>
23
24
  route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
24
25
  );
26
+ requestIds.push(resp?.requestId ?? null);
25
27
  }
26
- return rows.length;
28
+ return { count: rows.length, requestIds };
27
29
  }
28
30
 
29
31
  /**
@@ -33,7 +35,7 @@ export async function importRowsForDe(sdk, params) {
33
35
  * @param {string} params.deKey - target DE customer key for API
34
36
  * @param {'csv'|'tsv'|'json'} [params.format] - optional; auto-detected from file extension if omitted
35
37
  * @param {'upsert'|'insert'} params.mode
36
- * @returns {Promise.<number>} number of rows imported
38
+ * @returns {Promise.<{ count: number, requestIds: (string|null)[] }>}
37
39
  */
38
40
  export async function importFromFile(sdk, params) {
39
41
  const format = params.format || formatFromExtension(params.filePath);
@@ -21,6 +21,26 @@ export function asyncDataExtensionRowsPath(deKey) {
21
21
  return `/data/v1/async/dataextensions/key:${encodeURIComponent(deKey)}/rows`;
22
22
  }
23
23
 
24
+ /**
25
+ * Status endpoint for a previously submitted async request.
26
+ *
27
+ * @param {string} requestId
28
+ * @returns {string}
29
+ */
30
+ export function asyncRequestStatusPath(requestId) {
31
+ return `/data/v1/async/${encodeURIComponent(requestId)}/status`;
32
+ }
33
+
34
+ /**
35
+ * Results endpoint for a previously submitted async request.
36
+ *
37
+ * @param {string} requestId
38
+ * @returns {string}
39
+ */
40
+ export function asyncRequestResultsPath(requestId) {
41
+ return `/data/v1/async/${encodeURIComponent(requestId)}/results`;
42
+ }
43
+
24
44
  /**
25
45
  * @param {'upsert'|'insert'} mode
26
46
  * @returns {{ method: 'PUT'|'POST', path: (de: string) => string }}
package/lib/index.mjs CHANGED
@@ -5,7 +5,14 @@ export {
5
5
  parseExportBasename,
6
6
  } from './filename.mjs';
7
7
  export { chunkItemsForPayload, DEFAULT_MAX_BODY_BYTES, MAX_OBJECTS_PER_BATCH } from './batch.mjs';
8
- export { resolveImportRoute, rowsetGetPath, asyncDataExtensionRowsPath } from './import-routes.mjs';
8
+ export {
9
+ resolveImportRoute,
10
+ rowsetGetPath,
11
+ asyncDataExtensionRowsPath,
12
+ asyncRequestStatusPath,
13
+ asyncRequestResultsPath,
14
+ } from './import-routes.mjs';
15
+ export { pollAsyncImportCompletion } from './async-status.mjs';
9
16
  export {
10
17
  loadMcdevProject,
11
18
  parseCredBu,
@@ -14,4 +21,5 @@ export {
14
21
  } from './config.mjs';
15
22
  export { multiBuExport } from './multi-bu-export.mjs';
16
23
  export { crossBuImport } from './cross-bu-import.mjs';
24
+ export { getDeRowCount } from './row-count.mjs';
17
25
  export { projectRelativePosix } from './paths.mjs';
@@ -0,0 +1,20 @@
1
+ import { rowsetGetPath } from './import-routes.mjs';
2
+
3
+ /**
4
+ * Fetch the total row count for a Data Extension without downloading all rows.
5
+ * Uses a pagesize=1 request so only the `count` metadata is transferred.
6
+ * Returns `null` if the count cannot be determined (e.g. permissions error).
7
+ *
8
+ * @param {{ rest: { get: (path: string) => Promise.<any> } }} sdk
9
+ * @param {string} deKey - DE external key
10
+ * @returns {Promise.<number|null>}
11
+ */
12
+ export async function getDeRowCount(sdk, deKey) {
13
+ try {
14
+ const result = await sdk.rest.get(`${rowsetGetPath(deKey)}?$page=1&$pagesize=1`);
15
+ return result?.count ?? 0;
16
+ } catch (ex) {
17
+ console.error(`Could not retrieve row count for DE "${deKey}": ${ex.message}`);
18
+ return null;
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",
@@ -40,13 +40,14 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "csv-parser": "3.2.0",
43
- "csv-stringify": "6.5.2",
43
+ "csv-stringify": "6.7.0",
44
44
  "sfmc-sdk": "3.0.3"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --test test/*.test.js",
48
48
  "lint": "eslint .",
49
49
  "lint:fix": "eslint --fix .",
50
+ "prepare": "husky || true",
50
51
  "format": "prettier --write .",
51
52
  "format:check": "prettier --check ."
52
53
  },
@@ -58,6 +59,7 @@
58
59
  "eslint-plugin-prettier": "^5.5.0",
59
60
  "eslint-plugin-unicorn": "^64.0.0",
60
61
  "globals": "^17.4.0",
62
+ "husky": "^9.1.7",
61
63
  "prettier": "^3.8.1"
62
64
  }
63
65
  }