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 +1 -1
- package/lib/async-status.mjs +89 -0
- package/lib/cli.mjs +89 -14
- package/lib/cross-bu-import.mjs +54 -16
- package/lib/import-de.mjs +6 -4
- package/lib/import-routes.mjs +20 -0
- package/lib/index.mjs +9 -1
- package/lib/row-count.mjs +20 -0
- package/package.json +4 -2
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/lib/cross-bu-import.mjs
CHANGED
|
@@ -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.<
|
|
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, {
|
|
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.<
|
|
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.<
|
|
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);
|
package/lib/import-routes.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
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
|
}
|