sfmc-dataloader 2.3.0 → 2.4.2
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 +24 -11
- package/lib/cross-bu-import.mjs +49 -6
- package/lib/export-de.mjs +44 -5
- package/lib/import-de.mjs +13 -4
- package/lib/import-routes.mjs +20 -0
- package/lib/index.mjs +8 -1
- package/lib/read-rows.mjs +1 -1
- 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,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
|
|
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}
|
|
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}
|
|
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}`);
|
package/lib/cross-bu-import.mjs
CHANGED
|
@@ -4,9 +4,15 @@ 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
6
|
import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
fetchAllRowObjects,
|
|
9
|
+
fetchDataExtensionFieldNames,
|
|
10
|
+
serializeRows,
|
|
11
|
+
exportDataExtensionToFile,
|
|
12
|
+
} from './export-de.mjs';
|
|
8
13
|
import { formatFromExtension } from './file-resolve.mjs';
|
|
9
14
|
import { importRowsForDe } from './import-de.mjs';
|
|
15
|
+
import { pollAsyncImportCompletion } from './async-status.mjs';
|
|
10
16
|
import { readRowsFromFile } from './read-rows.mjs';
|
|
11
17
|
import { clearDataExtensionRows } from './clear-de.mjs';
|
|
12
18
|
import { confirmClearBeforeImport } from './confirm-clear.mjs';
|
|
@@ -82,7 +88,7 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
|
|
|
82
88
|
* @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
|
|
83
89
|
* @param {NodeJS.ReadableStream} [params.stdin] Override for testing
|
|
84
90
|
* @param {NodeJS.WritableStream} [params.stdout] Override for testing
|
|
85
|
-
* @returns {Promise.<
|
|
91
|
+
* @returns {Promise.<boolean>} true if at least one import job returned an error
|
|
86
92
|
*/
|
|
87
93
|
export async function crossBuImport(params) {
|
|
88
94
|
const {
|
|
@@ -169,6 +175,8 @@ export async function crossBuImport(params) {
|
|
|
169
175
|
await confirmClearBeforeImport({ deKeys, targets, acceptRiskFlag, isTTY, stdin, stdout });
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
let hasError = false;
|
|
179
|
+
|
|
172
180
|
// Load rows once per DE then fan out to every target
|
|
173
181
|
for (const deKey of deKeys) {
|
|
174
182
|
let rows;
|
|
@@ -181,10 +189,28 @@ export async function crossBuImport(params) {
|
|
|
181
189
|
);
|
|
182
190
|
}
|
|
183
191
|
rows = await readRowsFromFile(filePath, detectedFormat);
|
|
192
|
+
if (rows.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Import file contains no data rows: "${filePath}". ` +
|
|
195
|
+
`The file may be empty, contain only a BOM, or contain only a header row. ` +
|
|
196
|
+
`Export the DE first to obtain a template with column names, then add rows.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
184
199
|
} else {
|
|
185
200
|
rows = await fetchAllRowObjects(srcSdk, deKey);
|
|
186
201
|
}
|
|
187
202
|
|
|
203
|
+
let snapshotColumns = [];
|
|
204
|
+
if (rows.length === 0 && format !== 'json') {
|
|
205
|
+
try {
|
|
206
|
+
snapshotColumns = await fetchDataExtensionFieldNames(srcSdk.soap, deKey);
|
|
207
|
+
} catch (ex) {
|
|
208
|
+
console.error(
|
|
209
|
+
`Warning: could not retrieve field names for empty DE "${deKey}" (snapshot): ${ex.message}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
188
214
|
for (const { credential, bu } of targets) {
|
|
189
215
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
190
216
|
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
@@ -212,13 +238,26 @@ export async function crossBuImport(params) {
|
|
|
212
238
|
const ts = filesystemSafeTimestamp();
|
|
213
239
|
const basename = buildExportBasename(deKey, ts, format, false);
|
|
214
240
|
const snapshotPath = path.join(dir, basename);
|
|
215
|
-
await fs.writeFile(
|
|
241
|
+
await fs.writeFile(
|
|
242
|
+
snapshotPath,
|
|
243
|
+
serializeRows(rows, format, false, snapshotColumns),
|
|
244
|
+
'utf8',
|
|
245
|
+
);
|
|
216
246
|
const snapRel = projectRelativePosix(projectRoot, snapshotPath);
|
|
217
247
|
console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
|
|
218
248
|
|
|
219
|
-
const imported = await importRowsForDe(tgtSdk, {
|
|
249
|
+
const { count: imported, requestIds } = await importRowsForDe(tgtSdk, {
|
|
250
|
+
deKey,
|
|
251
|
+
rows,
|
|
252
|
+
mode,
|
|
253
|
+
});
|
|
220
254
|
console.error(`Imported: ${credential}/${bu} DE ${deKey} (${imported} rows)`);
|
|
221
255
|
|
|
256
|
+
const importHadError = await pollAsyncImportCompletion(tgtSdk, requestIds);
|
|
257
|
+
if (importHadError) {
|
|
258
|
+
hasError = true;
|
|
259
|
+
}
|
|
260
|
+
|
|
222
261
|
const countAfter = await getDeRowCount(tgtSdk, deKey);
|
|
223
262
|
console.error(
|
|
224
263
|
`Row count after import: ${countAfter ?? '(unavailable)'} (${credential}/${bu} DE "${deKey}")`,
|
|
@@ -229,13 +268,17 @@ export async function crossBuImport(params) {
|
|
|
229
268
|
);
|
|
230
269
|
} else {
|
|
231
270
|
const expected =
|
|
232
|
-
mode === 'insert' || countBefore === 0
|
|
271
|
+
mode === 'insert' || countBefore === 0
|
|
272
|
+
? (countBefore ?? 0) + imported
|
|
273
|
+
: imported;
|
|
233
274
|
if (countAfter < expected) {
|
|
234
275
|
console.error(
|
|
235
|
-
`Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}
|
|
276
|
+
`Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
|
|
236
277
|
);
|
|
237
278
|
}
|
|
238
279
|
}
|
|
239
280
|
}
|
|
240
281
|
}
|
|
282
|
+
|
|
283
|
+
return hasError;
|
|
241
284
|
}
|
package/lib/export-de.mjs
CHANGED
|
@@ -30,28 +30,57 @@ export async function fetchAllRowObjects(sdk, deKey) {
|
|
|
30
30
|
return rows;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Field names for a Data Extension, in ordinal order (SOAP).
|
|
35
|
+
*
|
|
36
|
+
* @param {{ retrieve: (type: string, props: string[], opts: object) => Promise.<any> }} soap
|
|
37
|
+
* @param {string} deKey - DE customer key
|
|
38
|
+
* @returns {Promise.<string[]>}
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchDataExtensionFieldNames(soap, deKey) {
|
|
41
|
+
const result = await soap.retrieve('DataExtensionField', ['Name', 'Ordinal'], {
|
|
42
|
+
filter: {
|
|
43
|
+
leftOperand: 'DataExtension.CustomerKey',
|
|
44
|
+
operator: 'equals',
|
|
45
|
+
rightOperand: deKey,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const fields = result?.Results;
|
|
49
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return fields
|
|
53
|
+
.toSorted((a, b) => Number(a.Ordinal ?? 0) - Number(b.Ordinal ?? 0))
|
|
54
|
+
.map((f) => f.Name);
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
/**
|
|
34
58
|
* @param {object[]} rows
|
|
35
59
|
* @param {'csv'|'tsv'|'json'} format
|
|
36
60
|
* @param {boolean} jsonPretty
|
|
61
|
+
* @param {string[]} [columns] - when `rows` is empty and format is csv/tsv, emit this header row
|
|
37
62
|
* @returns {string}
|
|
38
63
|
*/
|
|
39
|
-
export function serializeRows(rows, format, jsonPretty) {
|
|
64
|
+
export function serializeRows(rows, format, jsonPretty, columns = []) {
|
|
40
65
|
if (format === 'json') {
|
|
41
66
|
const space = jsonPretty ? 2 : undefined;
|
|
42
67
|
return JSON.stringify(rows, null, space) + '\n';
|
|
43
68
|
}
|
|
44
69
|
const delimiter = format === 'tsv' ? '\t' : ',';
|
|
45
|
-
|
|
70
|
+
const options = {
|
|
46
71
|
header: true,
|
|
47
72
|
quoted: format === 'csv',
|
|
48
73
|
bom: true,
|
|
49
74
|
delimiter,
|
|
50
|
-
}
|
|
75
|
+
};
|
|
76
|
+
if (rows.length === 0 && columns.length > 0) {
|
|
77
|
+
options.columns = columns;
|
|
78
|
+
}
|
|
79
|
+
return stringify(rows, options);
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
/**
|
|
54
|
-
* @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
|
|
83
|
+
* @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise.<any> }, soap: { retrieve: Function } }} sdk
|
|
55
84
|
* @param {object} params
|
|
56
85
|
* @param {string} params.projectRoot
|
|
57
86
|
* @param {string} params.credentialName
|
|
@@ -73,12 +102,22 @@ export async function exportDataExtensionToFile(sdk, params) {
|
|
|
73
102
|
useGit = false,
|
|
74
103
|
} = params;
|
|
75
104
|
const rows = await fetchAllRowObjects(sdk, deKey);
|
|
105
|
+
let columns = [];
|
|
106
|
+
if (rows.length === 0 && format !== 'json') {
|
|
107
|
+
try {
|
|
108
|
+
columns = await fetchDataExtensionFieldNames(sdk.soap, deKey);
|
|
109
|
+
} catch (ex) {
|
|
110
|
+
console.error(
|
|
111
|
+
`Warning: could not retrieve field names for empty DE "${deKey}": ${ex.message}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
76
115
|
const dir = dataDirectoryForBu(projectRoot, credentialName, buName);
|
|
77
116
|
await fs.mkdir(dir, { recursive: true });
|
|
78
117
|
const ts = filesystemSafeTimestamp();
|
|
79
118
|
const basename = buildExportBasename(deKey, ts, format, useGit);
|
|
80
119
|
const outPath = path.join(dir, basename);
|
|
81
|
-
const body = serializeRows(rows, format, jsonPretty);
|
|
120
|
+
const body = serializeRows(rows, format, jsonPretty, columns);
|
|
82
121
|
await fs.writeFile(outPath, body, 'utf8');
|
|
83
122
|
return { path: outPath, rowCount: rows.length };
|
|
84
123
|
}
|
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);
|
|
@@ -43,6 +45,13 @@ export async function importFromFile(sdk, params) {
|
|
|
43
45
|
);
|
|
44
46
|
}
|
|
45
47
|
const rows = await readRowsFromFile(params.filePath, format);
|
|
48
|
+
if (rows.length === 0) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Import file contains no data rows: "${params.filePath}". ` +
|
|
51
|
+
`The file may be empty, contain only a BOM, or contain only a header row. ` +
|
|
52
|
+
`Export the DE first to obtain a template with column names, then add rows.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
46
55
|
return importRowsForDe(sdk, {
|
|
47
56
|
deKey: params.deKey,
|
|
48
57
|
rows,
|
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,
|
package/lib/read-rows.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export async function readRowsFromFile(filePath, format) {
|
|
|
29
29
|
mapHeaders: ({ header }) => {
|
|
30
30
|
let h = header;
|
|
31
31
|
// Strip BOM if present (backup in case bom:true misses it)
|
|
32
|
-
if (h.
|
|
32
|
+
if (h.startsWith('\uFEFF')) {
|
|
33
33
|
h = h.slice(1);
|
|
34
34
|
}
|
|
35
35
|
// Strip surrounding quotes if present (non-standard quoted TSV)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfmc-dataloader",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.2",
|
|
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
|
}
|