sfmc-dataloader 2.6.1 → 2.7.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.
@@ -1,4 +1,5 @@
1
1
  import { asyncRequestResultsPath, asyncRequestStatusPath } from './import-routes.mjs';
2
+ import { log } from './log.mjs';
2
3
 
3
4
  const POLL_INTERVAL_MS = 5000;
4
5
  const PENDING_STATUSES = new Set(['Pending', 'Executing']);
@@ -25,11 +26,11 @@ export async function pollAsyncImportCompletion(sdk, requestIds) {
25
26
 
26
27
  for (const requestId of requestIds) {
27
28
  if (!requestId) {
28
- console.error('Async import: no requestId returned for chunk — skipping status check.');
29
+ log.error('Async import: no requestId returned for chunk — skipping status check.');
29
30
  continue;
30
31
  }
31
32
 
32
- console.error(`Waiting for async import to complete (requestId: ${requestId})…`);
33
+ log.info(`Waiting for async import to complete (requestId: ${requestId})…`);
33
34
  await sleep(POLL_INTERVAL_MS);
34
35
 
35
36
  let requestStatus;
@@ -38,7 +39,7 @@ export async function pollAsyncImportCompletion(sdk, requestIds) {
38
39
  try {
39
40
  statusResult = await sdk.rest.get(asyncRequestStatusPath(requestId));
40
41
  } catch (ex) {
41
- console.error(
42
+ log.error(
42
43
  `Async import: status check failed for requestId ${requestId}: ${ex.message}`,
43
44
  );
44
45
  break;
@@ -47,7 +48,7 @@ export async function pollAsyncImportCompletion(sdk, requestIds) {
47
48
  requestStatus = statusResult?.status?.requestStatus;
48
49
 
49
50
  if (PENDING_STATUSES.has(requestStatus)) {
50
- console.error(
51
+ log.info(
51
52
  `Async import still in progress (requestId: ${requestId}) — retrying in 5 s…`,
52
53
  );
53
54
  await sleep(POLL_INTERVAL_MS);
@@ -55,16 +56,16 @@ export async function pollAsyncImportCompletion(sdk, requestIds) {
55
56
  } while (PENDING_STATUSES.has(requestStatus));
56
57
 
57
58
  if (requestStatus === 'Complete') {
58
- console.error(`Async import completed successfully (requestId: ${requestId}).`);
59
+ log.info(`Async import completed successfully (requestId: ${requestId}).`);
59
60
  } else if (requestStatus === 'Error') {
60
- console.error(`Async import job failed (requestId: ${requestId}).`);
61
+ log.error(`Async import job failed (requestId: ${requestId}).`);
61
62
  hasError = true;
62
63
 
63
64
  let resultsResult;
64
65
  try {
65
66
  resultsResult = await sdk.rest.get(asyncRequestResultsPath(requestId));
66
67
  } catch (ex) {
67
- console.error(
68
+ log.error(
68
69
  `Async import: could not retrieve error details for requestId ${requestId}: ${ex.message}`,
69
70
  );
70
71
  continue;
@@ -72,14 +73,14 @@ export async function pollAsyncImportCompletion(sdk, requestIds) {
72
73
 
73
74
  const items = resultsResult?.items ?? [];
74
75
  if (items.length === 0) {
75
- console.error('Async import: no item-level error details returned by API.');
76
+ log.warn('Async import: no item-level error details returned by API.');
76
77
  } else {
77
78
  for (const item of items) {
78
- console.error(`Import error: ${item.message}`);
79
+ log.error(`Import error: ${item.message}`);
79
80
  }
80
81
  }
81
82
  } else if (requestStatus !== undefined) {
82
- console.error(
83
+ log.error(
83
84
  `Async import: unexpected status "${requestStatus}" for requestId ${requestId}.`,
84
85
  );
85
86
  }
package/lib/batch.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /** Default max UTF-8 bytes per request body (Data API family; margin below 5.9 MB). */
2
2
  export const DEFAULT_MAX_BODY_BYTES = 5_500_000;
3
3
 
4
- /** Salesforce hard cap on objects per batch. */
5
- export const MAX_OBJECTS_PER_BATCH = 5_000;
4
+ /** Max rows per HTTP payload chunk (byte cap in `DEFAULT_MAX_BODY_BYTES` may split further). */
5
+ export const MAX_OBJECTS_PER_BATCH = 2_500;
6
6
 
7
7
  /**
8
8
  * Split rows into chunks that respect both max row count and serialized JSON body size.
package/lib/cli.mjs CHANGED
@@ -13,9 +13,14 @@ import {
13
13
  } from './config.mjs';
14
14
  import { dataDirectoryForBu } from './paths.mjs';
15
15
  import { exportDataExtensionToFile } from './export-de.mjs';
16
- import { findImportCandidates, pickLatestByMtime } from './file-resolve.mjs';
16
+ import { findImportCandidates, formatFromExtension, resolveImportSet } from './file-resolve.mjs';
17
17
  import { parseExportBasename } from './filename.mjs';
18
- import { importFromFile } from './import-de.mjs';
18
+ import { MAX_OBJECTS_PER_BATCH } from './batch.mjs';
19
+ import {
20
+ assertNonEmptyImportRowCount,
21
+ importRowsStreamingForDe,
22
+ warnIfImportCountUnexpected,
23
+ } from './import-de.mjs';
19
24
  import { pollAsyncImportCompletion } from './async-status.mjs';
20
25
  import { clearDataExtensionRows } from './clear-de.mjs';
21
26
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
@@ -24,6 +29,24 @@ import { multiBuExport } from './multi-bu-export.mjs';
24
29
  import { crossBuImport } from './cross-bu-import.mjs';
25
30
  import { initDebugLogger } from './debug-logger.mjs';
26
31
  import { runMcdataInit } from './init-project.mjs';
32
+ import { countDataRowsFromImportPaths, streamRowsFromImportPaths } from './read-rows.mjs';
33
+ import { log, setDebugLogger, formatTime } from './log.mjs';
34
+
35
+ /**
36
+ * @param {string|undefined} raw
37
+ * @param {string} flagName
38
+ * @returns {number|undefined}
39
+ */
40
+ function parseOptionalPositiveInt(raw, flagName) {
41
+ if (raw === undefined || raw === '') {
42
+ return;
43
+ }
44
+ const n = Number(raw);
45
+ if (!Number.isFinite(n) || n < 1) {
46
+ throw new Error(`Invalid ${flagName}: ${raw} (expect positive integer)`);
47
+ }
48
+ return Math.floor(n);
49
+ }
27
50
 
28
51
  /** @returns {string} semver from this package's package.json */
29
52
  function readCliPackageVersion() {
@@ -53,6 +76,7 @@ Options:
53
76
  --format <csv|tsv|json> Export file format (default: csv); ignored for imports
54
77
  --json-pretty Pretty-print JSON on export
55
78
  --git Stable filenames: <key>.mcdata.<ext> (no timestamp)
79
+ --max-rows-per-file <n> Split exports into multiple files after N data rows (optional)
56
80
  --debug Write API requests/responses to ./logs/data/*.log
57
81
 
58
82
  Init options:
@@ -82,7 +106,7 @@ Config files:
82
106
 
83
107
  Notes:
84
108
  Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
85
- Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
109
+ Import with --de resolves the latest matching export (csv/tsv/json), including multi-part partN files.
86
110
  Import with --file parses the DE key from the basename (.mcdata. format).
87
111
  Import format is auto-detected from file extension (.csv, .tsv, .json).
88
112
  Cross-BU import stores a download file in each target BU's data directory.
@@ -128,12 +152,13 @@ export async function main(argv) {
128
152
  'auth-url': { type: 'string' },
129
153
  'enterprise-id': { type: 'string' },
130
154
  yes: { type: 'boolean', short: 'y', default: false },
155
+ 'max-rows-per-file': { type: 'string' },
131
156
  },
132
157
  });
133
158
  values = parsed.values;
134
159
  positionals = parsed.positionals;
135
160
  } catch (ex) {
136
- console.error(ex.message);
161
+ log.error(ex.message);
137
162
  printHelp();
138
163
  return 1;
139
164
  }
@@ -172,7 +197,7 @@ export async function main(argv) {
172
197
 
173
198
  const fmt = values.format ?? 'csv';
174
199
  if (!['csv', 'tsv', 'json'].includes(fmt)) {
175
- console.error(`Invalid --format: ${fmt}`);
200
+ log.error(`Invalid --format: ${fmt}`);
176
201
  return 1;
177
202
  }
178
203
 
@@ -195,31 +220,33 @@ export async function main(argv) {
195
220
  ? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
196
221
  : null;
197
222
  if (logger) {
198
- console.error(`Debug log: "${path.resolve(logger.logPath)}"`);
223
+ setDebugLogger(logger);
224
+ // Written directly to stdout — must not appear inside the file it announces
225
+ console.log(`${formatTime()} info: Debug log: "${path.resolve(logger.logPath)}"`);
199
226
  }
200
227
 
201
228
  // ── export ──────────────────────────────────────────────────────────────
202
229
  if (sub === 'export') {
203
230
  if (hasTo) {
204
- console.error('--to is not valid for export. Did you mean import?');
231
+ log.error('--to is not valid for export. Did you mean import?');
205
232
  return 1;
206
233
  }
207
234
 
208
235
  const des = [values.de ?? []].flat();
209
236
  if (des.length === 0) {
210
- console.error('export requires at least one --de <customerKey>');
237
+ log.error('export requires at least one --de <customerKey>');
211
238
  return 1;
212
239
  }
213
240
 
214
241
  if (hasFrom && hasPositional) {
215
- console.error(
242
+ log.error(
216
243
  'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
217
244
  );
218
245
  return 1;
219
246
  }
220
247
 
221
248
  if (!hasFrom && !hasPositional) {
222
- console.error(
249
+ log.error(
223
250
  'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
224
251
  );
225
252
  printHelp();
@@ -231,10 +258,20 @@ export async function main(argv) {
231
258
  try {
232
259
  sources = fromFlags.map(parseCredBu);
233
260
  } catch (ex) {
234
- console.error(ex.message);
261
+ log.error(ex.message);
235
262
  return 1;
236
263
  }
237
264
  const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
265
+ let maxRowsPerFile;
266
+ try {
267
+ maxRowsPerFile = parseOptionalPositiveInt(
268
+ values['max-rows-per-file'],
269
+ '--max-rows-per-file',
270
+ );
271
+ } catch (ex) {
272
+ log.error(ex.message);
273
+ return 1;
274
+ }
238
275
  await multiBuExport({
239
276
  projectRoot,
240
277
  mcdevrc,
@@ -244,17 +281,29 @@ export async function main(argv) {
244
281
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
245
282
  jsonPretty: values['json-pretty'],
246
283
  useGit,
284
+ maxRowsPerFile,
247
285
  logger,
248
286
  });
249
287
  return 0;
250
288
  }
251
289
 
290
+ let maxRowsPerFile;
291
+ try {
292
+ maxRowsPerFile = parseOptionalPositiveInt(
293
+ values['max-rows-per-file'],
294
+ '--max-rows-per-file',
295
+ );
296
+ } catch (ex) {
297
+ log.error(ex.message);
298
+ return 1;
299
+ }
300
+
252
301
  const { credential, bu } = parseCredBu(credBuRaw);
253
302
  const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
254
303
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
255
304
  const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
256
305
  for (const deKey of des) {
257
- const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
306
+ const { paths: outPaths, rowCount } = await exportDataExtensionToFile(sdk, {
258
307
  projectRoot,
259
308
  credentialName: credential,
260
309
  buName: bu,
@@ -262,8 +311,10 @@ export async function main(argv) {
262
311
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
263
312
  jsonPretty: values['json-pretty'],
264
313
  useGit,
314
+ maxRowsPerFile,
265
315
  });
266
- console.error(`Exported: "${path.resolve(out)}" (${rowCount} rows)`);
316
+ const label = outPaths.map((p) => `"${path.resolve(p)}"`).join(', ');
317
+ log.info(`Exported: ${label} (${rowCount} rows)`);
267
318
  }
268
319
  return 0;
269
320
  }
@@ -272,7 +323,7 @@ export async function main(argv) {
272
323
  if (sub === 'import') {
273
324
  const mode = values.mode ?? 'upsert';
274
325
  if (!['upsert', 'insert'].includes(mode)) {
275
- console.error(`Invalid --mode: ${mode} (use upsert or insert)`);
326
+ log.error(`Invalid --mode: ${mode} (use upsert or insert)`);
276
327
  return 1;
277
328
  }
278
329
 
@@ -282,15 +333,13 @@ export async function main(argv) {
282
333
  // ── File-to-multi-BU import: --to + --file (no --from) ─────────────
283
334
  if (hasTo && !hasFrom && values.file?.length > 0) {
284
335
  if (hasPositional) {
285
- console.error(
336
+ log.error(
286
337
  'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
287
338
  );
288
339
  return 1;
289
340
  }
290
341
  if (values.de?.length > 0) {
291
- console.error(
292
- 'Cannot mix --de with --file in multi-target import. Use --file only.',
293
- );
342
+ log.error('Cannot mix --de with --file in multi-target import. Use --file only.');
294
343
  return 1;
295
344
  }
296
345
  const filePaths = values.file;
@@ -298,7 +347,7 @@ export async function main(argv) {
298
347
  try {
299
348
  targets = toFlags.map(parseCredBu);
300
349
  } catch (ex) {
301
- console.error(ex.message);
350
+ log.error(ex.message);
302
351
  return 1;
303
352
  }
304
353
  const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
@@ -323,38 +372,36 @@ export async function main(argv) {
323
372
  // ── Cross-BU import (API mode): --from + --to + --de ────────────────
324
373
  if (hasFrom || hasTo) {
325
374
  if (hasPositional) {
326
- console.error(
375
+ log.error(
327
376
  'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
328
377
  );
329
378
  return 1;
330
379
  }
331
380
  if (!hasFrom) {
332
- console.error(
333
- '--to requires --from <cred>/<bu> to specify the source Business Unit.',
334
- );
381
+ log.error('--to requires --from <cred>/<bu> to specify the source Business Unit.');
335
382
  return 1;
336
383
  }
337
384
  if (!hasTo) {
338
- console.error(
385
+ log.error(
339
386
  '--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
340
387
  );
341
388
  return 1;
342
389
  }
343
390
  if (fromFlags.length > 1) {
344
- console.error(
391
+ log.error(
345
392
  'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
346
393
  );
347
394
  return 1;
348
395
  }
349
396
  if (values.file?.length > 0) {
350
- console.error(
397
+ log.error(
351
398
  '--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).',
352
399
  );
353
400
  return 1;
354
401
  }
355
402
  const deKeys = [values.de ?? []].flat();
356
403
  if (deKeys.length === 0) {
357
- console.error('Cross-BU import requires at least one --de <customerKey>.');
404
+ log.error('Cross-BU import requires at least one --de <customerKey>.');
358
405
  return 1;
359
406
  }
360
407
  let sourceParsed;
@@ -363,7 +410,7 @@ export async function main(argv) {
363
410
  sourceParsed = parseCredBu(fromFlags[0]);
364
411
  targets = toFlags.map(parseCredBu);
365
412
  } catch (ex) {
366
- console.error(ex.message);
413
+ log.error(ex.message);
367
414
  return 1;
368
415
  }
369
416
  const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
@@ -389,7 +436,7 @@ export async function main(argv) {
389
436
 
390
437
  // ── Single-BU import (original behavior) ────────────────────────────
391
438
  if (!hasPositional) {
392
- console.error(
439
+ log.error(
393
440
  'import requires either a positional <credential>/<bu> or --from/--to flags.',
394
441
  );
395
442
  printHelp();
@@ -399,7 +446,7 @@ export async function main(argv) {
399
446
  const hasDe = values.de?.length > 0;
400
447
  const hasFile = values.file?.length > 0;
401
448
  if (hasDe === hasFile) {
402
- console.error(
449
+ log.error(
403
450
  'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
404
451
  );
405
452
  return 1;
@@ -415,7 +462,7 @@ export async function main(argv) {
415
462
  const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
416
463
  if (backupBeforeImport === true) {
417
464
  for (const deKey of deKeys) {
418
- const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
465
+ const { paths: outPaths, rowCount } = await exportDataExtensionToFile(sdk, {
419
466
  projectRoot,
420
467
  credentialName: credential,
421
468
  buName: bu,
@@ -423,7 +470,8 @@ export async function main(argv) {
423
470
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
424
471
  useGit: false,
425
472
  });
426
- console.error(`Backup export: "${path.resolve(outPath)}" (${rowCount} rows)`);
473
+ const label = outPaths.map((p) => `"${path.resolve(p)}"`).join(', ');
474
+ log.info(`Backup export: ${label} (${rowCount} rows)`);
427
475
  }
428
476
  }
429
477
  if (clear) {
@@ -436,37 +484,49 @@ export async function main(argv) {
436
484
  let anyError = false;
437
485
  for (const deKey of deKeys) {
438
486
  const candidates = await findImportCandidates(dataDir, deKey);
439
- if (candidates.length === 0) {
440
- console.error(
487
+ const { paths: importPaths } = await resolveImportSet(candidates);
488
+ if (importPaths.length === 0) {
489
+ log.error(
441
490
  `No import file (csv/tsv/json) found for DE "${deKey}" under "${path.resolve(dataDir)}"`,
442
491
  );
443
492
  return 1;
444
493
  }
445
- const filePath =
446
- candidates.length === 1 ? candidates[0] : await pickLatestByMtime(candidates);
494
+ const detectedFormat = formatFromExtension(importPaths[0]);
495
+ if (!detectedFormat) {
496
+ log.error(
497
+ `Cannot determine format for "${importPaths[0]}". Use .csv, .tsv, or .json.`,
498
+ );
499
+ return 1;
500
+ }
447
501
 
448
502
  const countBefore = await getDeRowCount(sdk, deKey);
449
- console.error(
503
+ log.info(
450
504
  `Row count before import: ${countBefore ?? '(unavailable)'} (DE "${deKey}")`,
451
505
  );
452
506
 
507
+ let clearedSingleDe = false;
453
508
  if (clear) {
454
509
  if (countBefore === 0) {
455
- console.error(
456
- `Skipping clear-data for DE "${deKey}" — DE is already empty.`,
457
- );
510
+ log.info(`Skipping clear-data for DE "${deKey}" — DE is already empty.`);
458
511
  } else {
459
512
  await clearDataExtensionRows(sdk.soap, deKey);
460
- console.warn(`Cleared data: DE "${deKey}"`);
513
+ clearedSingleDe = true;
514
+ log.warn(`Cleared data: DE "${deKey}"`);
461
515
  }
462
516
  }
463
517
 
464
- const { count: n, requestIds } = await importFromFile(sdk, {
465
- filePath,
518
+ const rowCount = await countDataRowsFromImportPaths(importPaths, detectedFormat);
519
+ assertNonEmptyImportRowCount(rowCount, importPaths.join(', '));
520
+ const totalMemoryBatches = Math.max(1, Math.ceil(rowCount / MAX_OBJECTS_PER_BATCH));
521
+ const rowSource = streamRowsFromImportPaths(importPaths, detectedFormat);
522
+ const { count: n, requestIds } = await importRowsStreamingForDe(sdk, {
466
523
  deKey,
524
+ rowSource,
467
525
  mode: /** @type {'upsert'|'insert'} */ (mode),
526
+ totalMemoryBatches,
468
527
  });
469
- console.error(`Imported: "${path.resolve(filePath)}" (${n} rows) -> DE ${deKey}`);
528
+ const srcLabel = importPaths.map((p) => `"${path.resolve(p)}"`).join(', ');
529
+ log.info(`Imported: ${srcLabel} (${n} rows) -> DE ${deKey}`);
470
530
 
471
531
  const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
472
532
  if (importHadError) {
@@ -474,32 +534,35 @@ export async function main(argv) {
474
534
  }
475
535
 
476
536
  const countAfter = await getDeRowCount(sdk, deKey);
477
- console.error(
537
+ log.info(
478
538
  `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${deKey}")`,
479
539
  );
480
- if (countAfter === null) {
481
- console.error(`Could not verify import result for DE "${deKey}".`);
482
- } else if (countBefore !== null) {
483
- // Insert: expect countBefore + n; upsert on empty: same; upsert on non-empty: expect >= n
484
- const expected =
485
- mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
486
- if (countAfter < expected) {
487
- console.error(
488
- `Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
489
- );
490
- }
491
- }
540
+ warnIfImportCountUnexpected({
541
+ countBefore,
542
+ cleared: clearedSingleDe,
543
+ countAfter,
544
+ imported: n,
545
+ mode: /** @type {'upsert'|'insert'} */ (mode),
546
+ label: `DE "${deKey}"`,
547
+ });
492
548
  }
493
549
  return anyError ? 1 : 0;
494
550
  }
495
551
 
496
552
  const fileList = values.file ?? [];
497
- const keysFromFiles = fileList.map(
498
- (fp) => parseExportBasename(path.basename(fp)).customerKey,
499
- );
553
+ /** @type {string[]} */
554
+ const keysFromFiles = [];
555
+ const seenKeys = new Set();
556
+ for (const fp of fileList) {
557
+ const k = parseExportBasename(path.basename(fp)).customerKey;
558
+ if (!seenKeys.has(k)) {
559
+ seenKeys.add(k);
560
+ keysFromFiles.push(k);
561
+ }
562
+ }
500
563
  if (backupBeforeImport === true) {
501
564
  for (const deKey of keysFromFiles) {
502
- const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
565
+ const { paths: outPaths, rowCount } = await exportDataExtensionToFile(sdk, {
503
566
  projectRoot,
504
567
  credentialName: credential,
505
568
  buName: bu,
@@ -507,7 +570,8 @@ export async function main(argv) {
507
570
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
508
571
  useGit: false,
509
572
  });
510
- console.error(`Backup export: "${path.resolve(outPath)}" (${rowCount} rows)`);
573
+ const label = outPaths.map((p) => `"${path.resolve(p)}"`).join(', ');
574
+ log.info(`Backup export: ${label} (${rowCount} rows)`);
511
575
  }
512
576
  }
513
577
  if (clear) {
@@ -517,33 +581,64 @@ export async function main(argv) {
517
581
  isTTY: process.stdin.isTTY === true,
518
582
  });
519
583
  }
584
+ /** @type {Map<string, string[]>} */
585
+ const pathsByDeKey = new Map();
586
+ for (const fp of fileList) {
587
+ const { customerKey } = parseExportBasename(path.basename(fp));
588
+ const list = pathsByDeKey.get(customerKey);
589
+ if (list) {
590
+ list.push(fp);
591
+ } else {
592
+ pathsByDeKey.set(customerKey, [fp]);
593
+ }
594
+ }
595
+
520
596
  let anyFileError = false;
521
- for (const filePath of fileList) {
522
- const base = path.basename(filePath);
523
- const { customerKey } = parseExportBasename(base);
597
+ for (const customerKey of keysFromFiles) {
598
+ const groupPaths = pathsByDeKey.get(customerKey) ?? [];
599
+ const { paths: importPaths } = await resolveImportSet(groupPaths);
600
+ if (importPaths.length === 0) {
601
+ log.error(`No resolvable import files for DE "${customerKey}".`);
602
+ anyFileError = true;
603
+ continue;
604
+ }
605
+ const detectedFormat = formatFromExtension(importPaths[0]);
606
+ if (!detectedFormat) {
607
+ log.error(
608
+ `Cannot determine format for "${importPaths[0]}". Use .csv, .tsv, or .json.`,
609
+ );
610
+ anyFileError = true;
611
+ continue;
612
+ }
524
613
 
525
614
  const countBefore = await getDeRowCount(sdk, customerKey);
526
- console.error(
615
+ log.info(
527
616
  `Row count before import: ${countBefore ?? '(unavailable)'} (DE "${customerKey}")`,
528
617
  );
529
618
 
619
+ let clearedMultiFileDe = false;
530
620
  if (clear) {
531
621
  if (countBefore === 0) {
532
- console.error(
533
- `Skipping clear-data for DE "${customerKey}" — DE is already empty.`,
534
- );
622
+ log.info(`Skipping clear-data for DE "${customerKey}" — DE is already empty.`);
535
623
  } else {
536
624
  await clearDataExtensionRows(sdk.soap, customerKey);
537
- console.warn(`Cleared data: DE "${customerKey}"`);
625
+ clearedMultiFileDe = true;
626
+ log.warn(`Cleared data: DE "${customerKey}"`);
538
627
  }
539
628
  }
540
629
 
541
- const { count: n, requestIds } = await importFromFile(sdk, {
542
- filePath,
630
+ const rowCount = await countDataRowsFromImportPaths(importPaths, detectedFormat);
631
+ assertNonEmptyImportRowCount(rowCount, importPaths.join(', '));
632
+ const totalMemoryBatches = Math.max(1, Math.ceil(rowCount / MAX_OBJECTS_PER_BATCH));
633
+ const rowSource = streamRowsFromImportPaths(importPaths, detectedFormat);
634
+ const { count: n, requestIds } = await importRowsStreamingForDe(sdk, {
543
635
  deKey: customerKey,
636
+ rowSource,
544
637
  mode: /** @type {'upsert'|'insert'} */ (mode),
638
+ totalMemoryBatches,
545
639
  });
546
- console.error(`Imported: "${path.resolve(filePath)}" (${n} rows)`);
640
+ const srcLabel = importPaths.map((p) => `"${path.resolve(p)}"`).join(', ');
641
+ log.info(`Imported: ${srcLabel} (${n} rows)`);
547
642
 
548
643
  const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
549
644
  if (importHadError) {
@@ -551,25 +646,22 @@ export async function main(argv) {
551
646
  }
552
647
 
553
648
  const countAfter = await getDeRowCount(sdk, customerKey);
554
- console.error(
649
+ log.info(
555
650
  `Row count after import: ${countAfter ?? '(unavailable)'} (DE "${customerKey}")`,
556
651
  );
557
- if (countAfter === null) {
558
- console.error(`Could not verify import result for DE "${customerKey}".`);
559
- } else {
560
- const expected =
561
- mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
562
- if (countAfter < expected) {
563
- console.error(
564
- `Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
565
- );
566
- }
567
- }
652
+ warnIfImportCountUnexpected({
653
+ countBefore,
654
+ cleared: clearedMultiFileDe,
655
+ countAfter,
656
+ imported: n,
657
+ mode: /** @type {'upsert'|'insert'} */ (mode),
658
+ label: `DE "${customerKey}"`,
659
+ });
568
660
  }
569
661
  return anyFileError ? 1 : 0;
570
662
  }
571
663
 
572
- console.error(`Unknown command: ${sub}`);
664
+ log.error(`Unknown command: ${sub}`);
573
665
  printHelp();
574
666
  return 1;
575
667
  }