sfmc-dataloader 2.3.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,6 +16,7 @@ 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';
21
22
  import { getDeRowCount } from './row-count.mjs';
@@ -267,7 +268,7 @@ export async function main(argv) {
267
268
  return 1;
268
269
  }
269
270
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
270
- await crossBuImport({
271
+ const crossBuHadError = await crossBuImport({
271
272
  projectRoot,
272
273
  mcdevrc,
273
274
  mcdevAuth,
@@ -282,7 +283,7 @@ export async function main(argv) {
282
283
  useGit,
283
284
  logger,
284
285
  });
285
- return 0;
286
+ return crossBuHadError ? 1 : 0;
286
287
  }
287
288
 
288
289
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
@@ -332,7 +333,7 @@ export async function main(argv) {
332
333
  return 1;
333
334
  }
334
335
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
335
- await crossBuImport({
336
+ const crossBuApiHadError = await crossBuImport({
336
337
  projectRoot,
337
338
  mcdevrc,
338
339
  mcdevAuth,
@@ -349,7 +350,7 @@ export async function main(argv) {
349
350
  useGit,
350
351
  logger,
351
352
  });
352
- return 0;
353
+ return crossBuApiHadError ? 1 : 0;
353
354
  }
354
355
 
355
356
  // ── Single-BU import (original behavior) ────────────────────────────
@@ -400,6 +401,7 @@ export async function main(argv) {
400
401
  isTTY: process.stdin.isTTY === true,
401
402
  });
402
403
  }
404
+ let anyError = false;
403
405
  for (const deKey of deKeys) {
404
406
  const candidates = await findImportCandidates(dataDir, deKey);
405
407
  if (candidates.length === 0) {
@@ -427,7 +429,7 @@ export async function main(argv) {
427
429
  }
428
430
  }
429
431
 
430
- const n = await importFromFile(sdk, {
432
+ const { count: n, requestIds } = await importFromFile(sdk, {
431
433
  filePath,
432
434
  deKey,
433
435
  mode: /** @type {'upsert'|'insert'} */ (mode),
@@ -435,24 +437,29 @@ export async function main(argv) {
435
437
  const rel = projectRelativePosix(projectRoot, filePath);
436
438
  console.error(`Imported: ${rel} (${n} rows) -> DE ${deKey}`);
437
439
 
440
+ const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
441
+ if (importHadError) {
442
+ anyError = true;
443
+ }
444
+
438
445
  const countAfter = await getDeRowCount(sdk, deKey);
439
446
  console.error(
440
447
  `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${deKey}")`,
441
448
  );
442
449
  if (countAfter === null) {
443
450
  console.error(`Could not verify import result for DE "${deKey}".`);
444
- } else if (countBefore !== null && countAfter < (countBefore ?? 0) + n) {
451
+ } else if (countBefore !== null) {
445
452
  // Insert: expect countBefore + n; upsert on empty: same; upsert on non-empty: expect >= n
446
453
  const expected =
447
454
  mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
448
455
  if (countAfter < expected) {
449
456
  console.error(
450
- `Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}. Note: the async API may not have committed all rows yet.`,
457
+ `Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
451
458
  );
452
459
  }
453
460
  }
454
461
  }
455
- return 0;
462
+ return anyError ? 1 : 0;
456
463
  }
457
464
 
458
465
  const fileList = values.file ?? [];
@@ -481,6 +488,7 @@ export async function main(argv) {
481
488
  isTTY: process.stdin.isTTY === true,
482
489
  });
483
490
  }
491
+ let anyFileError = false;
484
492
  for (const filePath of fileList) {
485
493
  const base = path.basename(filePath);
486
494
  const { customerKey } = parseExportBasename(base);
@@ -501,7 +509,7 @@ export async function main(argv) {
501
509
  }
502
510
  }
503
511
 
504
- const n = await importFromFile(sdk, {
512
+ const { count: n, requestIds } = await importFromFile(sdk, {
505
513
  filePath,
506
514
  deKey: customerKey,
507
515
  mode: /** @type {'upsert'|'insert'} */ (mode),
@@ -509,6 +517,11 @@ export async function main(argv) {
509
517
  const rel = projectRelativePosix(projectRoot, filePath);
510
518
  console.error(`Imported: ${rel} (${n} rows)`);
511
519
 
520
+ const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
521
+ if (importHadError) {
522
+ anyFileError = true;
523
+ }
524
+
512
525
  const countAfter = await getDeRowCount(sdk, customerKey);
513
526
  console.error(
514
527
  `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${customerKey}")`,
@@ -520,12 +533,12 @@ export async function main(argv) {
520
533
  mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
521
534
  if (countAfter < expected) {
522
535
  console.error(
523
- `Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}. Note: the async API may not have committed all rows yet.`,
536
+ `Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
524
537
  );
525
538
  }
526
539
  }
527
540
  }
528
- return 0;
541
+ return anyFileError ? 1 : 0;
529
542
  }
530
543
 
531
544
  console.error(`Unknown command: ${sub}`);
@@ -7,6 +7,7 @@ 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';
@@ -82,7 +83,7 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
82
83
  * @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
83
84
  * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
84
85
  * @param {NodeJS.WritableStream} [params.stdout] Override for testing
85
- * @returns {Promise.<void>}
86
+ * @returns {Promise.<boolean>} true if at least one import job returned an error
86
87
  */
87
88
  export async function crossBuImport(params) {
88
89
  const {
@@ -169,6 +170,8 @@ export async function crossBuImport(params) {
169
170
  await confirmClearBeforeImport({ deKeys, targets, acceptRiskFlag, isTTY, stdin, stdout });
170
171
  }
171
172
 
173
+ let hasError = false;
174
+
172
175
  // Load rows once per DE then fan out to every target
173
176
  for (const deKey of deKeys) {
174
177
  let rows;
@@ -216,9 +219,18 @@ export async function crossBuImport(params) {
216
219
  const snapRel = projectRelativePosix(projectRoot, snapshotPath);
217
220
  console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
218
221
 
219
- const imported = await importRowsForDe(tgtSdk, { deKey, rows, mode });
222
+ const { count: imported, requestIds } = await importRowsForDe(tgtSdk, {
223
+ deKey,
224
+ rows,
225
+ mode,
226
+ });
220
227
  console.error(`Imported: ${credential}/${bu} DE ${deKey} (${imported} rows)`);
221
228
 
229
+ const importHadError = await pollAsyncImportCompletion(tgtSdk, requestIds);
230
+ if (importHadError) {
231
+ hasError = true;
232
+ }
233
+
222
234
  const countAfter = await getDeRowCount(tgtSdk, deKey);
223
235
  console.error(
224
236
  `Row count after import: ${countAfter ?? '(unavailable)'} (${credential}/${bu} DE "${deKey}")`,
@@ -229,13 +241,17 @@ export async function crossBuImport(params) {
229
241
  );
230
242
  } else {
231
243
  const expected =
232
- mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + imported : imported;
244
+ mode === 'insert' || countBefore === 0
245
+ ? (countBefore ?? 0) + imported
246
+ : imported;
233
247
  if (countAfter < expected) {
234
248
  console.error(
235
- `Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}. Note: the async API may not have committed all rows yet.`,
249
+ `Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
236
250
  );
237
251
  }
238
252
  }
239
253
  }
240
254
  }
255
+
256
+ return hasError;
241
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "2.3.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
  }