sfmc-dataloader 2.0.1 → 2.1.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 +10 -10
- package/bin/mcdata.mjs +2 -2
- package/lib/clear-de.mjs +1 -1
- package/lib/cli.mjs +77 -32
- package/lib/config.mjs +51 -4
- package/lib/confirm-clear.mjs +20 -24
- package/lib/cross-bu-import.mjs +42 -16
- package/lib/debug-logger.mjs +42 -0
- package/lib/export-de.mjs +24 -7
- package/lib/file-resolve.mjs +30 -5
- package/lib/filename.mjs +18 -17
- package/lib/import-de.mjs +12 -7
- package/lib/import-routes.mjs +1 -0
- package/lib/index.mjs +11 -2
- package/lib/multi-bu-export.mjs +15 -4
- package/lib/read-rows.mjs +21 -2
- package/lib/retry.mjs +11 -10
- package/package.json +16 -2
package/README.md
CHANGED
|
@@ -101,16 +101,16 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
|
|
|
101
101
|
|
|
102
102
|
## Options
|
|
103
103
|
|
|
104
|
-
| Option
|
|
105
|
-
|
|
106
|
-
| `-p, --project`
|
|
107
|
-
| `--format`
|
|
108
|
-
| `--git`
|
|
109
|
-
| `--mode`
|
|
110
|
-
| `--from <cred>/<bu>`
|
|
111
|
-
| `--to <cred>/<bu>`
|
|
112
|
-
| `--clear-before-import`
|
|
113
|
-
| `--i-accept-clear-data-risk` | Non-interactive consent for clear
|
|
104
|
+
| Option | Description |
|
|
105
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
106
|
+
| `-p, --project` | Project root (default: cwd) |
|
|
107
|
+
| `--format` | `csv` (default), `tsv`, or `json` |
|
|
108
|
+
| `--git` | Stable export filenames: `<key>.mcdata.<ext>` (no timestamp segment) |
|
|
109
|
+
| `--mode` | `upsert` (default) or `insert` — async bulk REST API only |
|
|
110
|
+
| `--from <cred>/<bu>` | Export: source BU (repeatable). Import API mode: single source BU (use with `--to` and `--de`) |
|
|
111
|
+
| `--to <cred>/<bu>` | Import: target BU (repeatable). API mode: use with `--from`/`--de`. File mode: use with `--file` (no `--from` needed) |
|
|
112
|
+
| `--clear-before-import` | SOAP `ClearData` before REST import |
|
|
113
|
+
| `--i-accept-clear-data-risk` | Non-interactive consent for clear |
|
|
114
114
|
|
|
115
115
|
Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
|
|
116
116
|
|
package/bin/mcdata.mjs
CHANGED
package/lib/clear-de.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @param {*} soap - SDK soap instance
|
|
6
6
|
* @param {string} customerKey - DE external key
|
|
7
|
-
* @returns {Promise
|
|
7
|
+
* @returns {Promise.<any>}
|
|
8
8
|
*/
|
|
9
9
|
export async function clearDataExtensionRows(soap, customerKey) {
|
|
10
10
|
return soap.perform('DataExtension', 'ClearData', {
|
package/lib/cli.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
parseCredBu,
|
|
10
10
|
resolveCredentialAndMid,
|
|
11
11
|
buildSdkAuthObject,
|
|
12
|
+
buildSdkOptions,
|
|
12
13
|
} from './config.mjs';
|
|
13
14
|
import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
|
|
14
15
|
import { exportDataExtensionToFile } from './export-de.mjs';
|
|
@@ -19,9 +20,14 @@ import { clearDataExtensionRows } from './clear-de.mjs';
|
|
|
19
20
|
import { confirmClearBeforeImport } from './confirm-clear.mjs';
|
|
20
21
|
import { multiBuExport } from './multi-bu-export.mjs';
|
|
21
22
|
import { crossBuImport } from './cross-bu-import.mjs';
|
|
23
|
+
import { initDebugLogger } from './debug-logger.mjs';
|
|
22
24
|
|
|
23
25
|
/** @returns {string} semver from this package's package.json */
|
|
24
26
|
function readCliPackageVersion() {
|
|
27
|
+
const injected = globalThis.__sfmc_dataloader_version__;
|
|
28
|
+
if (typeof injected === 'string' && injected.length > 0) {
|
|
29
|
+
return injected;
|
|
30
|
+
}
|
|
25
31
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
26
32
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
27
33
|
return typeof pkg.version === 'string' ? pkg.version : '';
|
|
@@ -40,9 +46,10 @@ Usage:
|
|
|
40
46
|
Options:
|
|
41
47
|
--version Print version and exit
|
|
42
48
|
-p, --project <dir> mcdev project root (default: cwd)
|
|
43
|
-
--format <csv|tsv|json>
|
|
49
|
+
--format <csv|tsv|json> Export file format (default: csv); ignored for imports
|
|
44
50
|
--json-pretty Pretty-print JSON on export
|
|
45
51
|
--git Stable filenames: <key>.mcdata.<ext> (no timestamp)
|
|
52
|
+
--debug Write API requests/responses to ./logs/data/*.log
|
|
46
53
|
|
|
47
54
|
Import options:
|
|
48
55
|
--mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
|
|
@@ -57,8 +64,9 @@ Multi-BU options:
|
|
|
57
64
|
|
|
58
65
|
Notes:
|
|
59
66
|
Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
|
|
60
|
-
Import with --de resolves the latest matching file in that folder (by mtime).
|
|
67
|
+
Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
|
|
61
68
|
Import with --file parses the DE key from the basename (.mcdata. format).
|
|
69
|
+
Import format is auto-detected from file extension (.csv, .tsv, .json).
|
|
62
70
|
Cross-BU import stores a download file in each target BU's data directory.
|
|
63
71
|
|
|
64
72
|
Clear data warning:
|
|
@@ -69,7 +77,7 @@ Clear data warning:
|
|
|
69
77
|
|
|
70
78
|
/**
|
|
71
79
|
* @param {string[]} argv
|
|
72
|
-
* @returns {Promise
|
|
80
|
+
* @returns {Promise.<number>} exit code
|
|
73
81
|
*/
|
|
74
82
|
export async function main(argv) {
|
|
75
83
|
let values;
|
|
@@ -91,14 +99,15 @@ export async function main(argv) {
|
|
|
91
99
|
'clear-before-import': { type: 'boolean', default: false },
|
|
92
100
|
'i-accept-clear-data-risk': { type: 'boolean', default: false },
|
|
93
101
|
'json-pretty': { type: 'boolean', default: false },
|
|
102
|
+
debug: { type: 'boolean', default: false },
|
|
94
103
|
help: { type: 'boolean', short: 'h', default: false },
|
|
95
104
|
version: { type: 'boolean', default: false },
|
|
96
105
|
},
|
|
97
106
|
});
|
|
98
107
|
values = parsed.values;
|
|
99
108
|
positionals = parsed.positionals;
|
|
100
|
-
} catch (
|
|
101
|
-
console.error(
|
|
109
|
+
} catch (ex) {
|
|
110
|
+
console.error(ex.message);
|
|
102
111
|
printHelp();
|
|
103
112
|
return 1;
|
|
104
113
|
}
|
|
@@ -133,6 +142,15 @@ export async function main(argv) {
|
|
|
133
142
|
const hasTo = toFlags.length > 0;
|
|
134
143
|
const hasPositional = !!credBuRaw;
|
|
135
144
|
|
|
145
|
+
// Initialize debug logger if --debug flag is set
|
|
146
|
+
/** @type {import('./debug-logger.mjs').DebugLogger|null} */
|
|
147
|
+
const logger = values.debug
|
|
148
|
+
? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
|
|
149
|
+
: null;
|
|
150
|
+
if (logger) {
|
|
151
|
+
console.error(`Debug log: ${projectRelativePosix(projectRoot, logger.logPath)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
// ── export ──────────────────────────────────────────────────────────────
|
|
137
155
|
if (sub === 'export') {
|
|
138
156
|
if (hasTo) {
|
|
@@ -140,19 +158,23 @@ export async function main(argv) {
|
|
|
140
158
|
return 1;
|
|
141
159
|
}
|
|
142
160
|
|
|
143
|
-
const des = [
|
|
161
|
+
const des = [values.de ?? []].flat();
|
|
144
162
|
if (des.length === 0) {
|
|
145
163
|
console.error('export requires at least one --de <customerKey>');
|
|
146
164
|
return 1;
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
if (hasFrom && hasPositional) {
|
|
150
|
-
console.error(
|
|
168
|
+
console.error(
|
|
169
|
+
'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
|
|
170
|
+
);
|
|
151
171
|
return 1;
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
if (!hasFrom && !hasPositional) {
|
|
155
|
-
console.error(
|
|
175
|
+
console.error(
|
|
176
|
+
'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
|
|
177
|
+
);
|
|
156
178
|
printHelp();
|
|
157
179
|
return 1;
|
|
158
180
|
}
|
|
@@ -161,8 +183,8 @@ export async function main(argv) {
|
|
|
161
183
|
let sources;
|
|
162
184
|
try {
|
|
163
185
|
sources = fromFlags.map(parseCredBu);
|
|
164
|
-
} catch (
|
|
165
|
-
console.error(
|
|
186
|
+
} catch (ex) {
|
|
187
|
+
console.error(ex.message);
|
|
166
188
|
return 1;
|
|
167
189
|
}
|
|
168
190
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -175,6 +197,7 @@ export async function main(argv) {
|
|
|
175
197
|
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
176
198
|
jsonPretty: values['json-pretty'],
|
|
177
199
|
useGit,
|
|
200
|
+
logger,
|
|
178
201
|
});
|
|
179
202
|
return 0;
|
|
180
203
|
}
|
|
@@ -182,7 +205,7 @@ export async function main(argv) {
|
|
|
182
205
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
183
206
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
184
207
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
185
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
208
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
186
209
|
for (const deKey of des) {
|
|
187
210
|
const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
188
211
|
projectRoot,
|
|
@@ -213,19 +236,23 @@ export async function main(argv) {
|
|
|
213
236
|
// ── File-to-multi-BU import: --to + --file (no --from) ─────────────
|
|
214
237
|
if (hasTo && !hasFrom && values.file?.length > 0) {
|
|
215
238
|
if (hasPositional) {
|
|
216
|
-
console.error(
|
|
239
|
+
console.error(
|
|
240
|
+
'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
|
|
241
|
+
);
|
|
217
242
|
return 1;
|
|
218
243
|
}
|
|
219
244
|
if (values.de?.length > 0) {
|
|
220
|
-
console.error(
|
|
245
|
+
console.error(
|
|
246
|
+
'Cannot mix --de with --file in multi-target import. Use --file only.',
|
|
247
|
+
);
|
|
221
248
|
return 1;
|
|
222
249
|
}
|
|
223
250
|
const filePaths = values.file;
|
|
224
251
|
let targets;
|
|
225
252
|
try {
|
|
226
253
|
targets = toFlags.map(parseCredBu);
|
|
227
|
-
} catch (
|
|
228
|
-
console.error(
|
|
254
|
+
} catch (ex) {
|
|
255
|
+
console.error(ex.message);
|
|
229
256
|
return 1;
|
|
230
257
|
}
|
|
231
258
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -241,6 +268,7 @@ export async function main(argv) {
|
|
|
241
268
|
acceptRiskFlag: acceptRisk,
|
|
242
269
|
isTTY: process.stdin.isTTY === true,
|
|
243
270
|
useGit,
|
|
271
|
+
logger,
|
|
244
272
|
});
|
|
245
273
|
return 0;
|
|
246
274
|
}
|
|
@@ -248,26 +276,36 @@ export async function main(argv) {
|
|
|
248
276
|
// ── Cross-BU import (API mode): --from + --to + --de ────────────────
|
|
249
277
|
if (hasFrom || hasTo) {
|
|
250
278
|
if (hasPositional) {
|
|
251
|
-
console.error(
|
|
279
|
+
console.error(
|
|
280
|
+
'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
|
|
281
|
+
);
|
|
252
282
|
return 1;
|
|
253
283
|
}
|
|
254
284
|
if (!hasFrom) {
|
|
255
|
-
console.error(
|
|
285
|
+
console.error(
|
|
286
|
+
'--to requires --from <cred>/<bu> to specify the source Business Unit.',
|
|
287
|
+
);
|
|
256
288
|
return 1;
|
|
257
289
|
}
|
|
258
290
|
if (!hasTo) {
|
|
259
|
-
console.error(
|
|
291
|
+
console.error(
|
|
292
|
+
'--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
|
|
293
|
+
);
|
|
260
294
|
return 1;
|
|
261
295
|
}
|
|
262
296
|
if (fromFlags.length > 1) {
|
|
263
|
-
console.error(
|
|
297
|
+
console.error(
|
|
298
|
+
'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
|
|
299
|
+
);
|
|
264
300
|
return 1;
|
|
265
301
|
}
|
|
266
302
|
if (values.file?.length > 0) {
|
|
267
|
-
console.error(
|
|
303
|
+
console.error(
|
|
304
|
+
'--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).',
|
|
305
|
+
);
|
|
268
306
|
return 1;
|
|
269
307
|
}
|
|
270
|
-
const deKeys = [
|
|
308
|
+
const deKeys = [values.de ?? []].flat();
|
|
271
309
|
if (deKeys.length === 0) {
|
|
272
310
|
console.error('Cross-BU import requires at least one --de <customerKey>.');
|
|
273
311
|
return 1;
|
|
@@ -277,8 +315,8 @@ export async function main(argv) {
|
|
|
277
315
|
try {
|
|
278
316
|
sourceParsed = parseCredBu(fromFlags[0]);
|
|
279
317
|
targets = toFlags.map(parseCredBu);
|
|
280
|
-
} catch (
|
|
281
|
-
console.error(
|
|
318
|
+
} catch (ex) {
|
|
319
|
+
console.error(ex.message);
|
|
282
320
|
return 1;
|
|
283
321
|
}
|
|
284
322
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -296,13 +334,16 @@ export async function main(argv) {
|
|
|
296
334
|
acceptRiskFlag: acceptRisk,
|
|
297
335
|
isTTY: process.stdin.isTTY === true,
|
|
298
336
|
useGit,
|
|
337
|
+
logger,
|
|
299
338
|
});
|
|
300
339
|
return 0;
|
|
301
340
|
}
|
|
302
341
|
|
|
303
342
|
// ── Single-BU import (original behavior) ────────────────────────────
|
|
304
343
|
if (!hasPositional) {
|
|
305
|
-
console.error(
|
|
344
|
+
console.error(
|
|
345
|
+
'import requires either a positional <credential>/<bu> or --from/--to flags.',
|
|
346
|
+
);
|
|
306
347
|
printHelp();
|
|
307
348
|
return 1;
|
|
308
349
|
}
|
|
@@ -310,17 +351,19 @@ export async function main(argv) {
|
|
|
310
351
|
const hasDe = values.de?.length > 0;
|
|
311
352
|
const hasFile = values.file?.length > 0;
|
|
312
353
|
if (hasDe === hasFile) {
|
|
313
|
-
console.error(
|
|
354
|
+
console.error(
|
|
355
|
+
'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
|
|
356
|
+
);
|
|
314
357
|
return 1;
|
|
315
358
|
}
|
|
316
359
|
|
|
317
360
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
318
361
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
319
362
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
320
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
363
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
321
364
|
|
|
322
365
|
if (hasDe) {
|
|
323
|
-
const deKeys = [
|
|
366
|
+
const deKeys = [values.de ?? []].flat();
|
|
324
367
|
const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
|
|
325
368
|
if (clear) {
|
|
326
369
|
await confirmClearBeforeImport({
|
|
@@ -333,9 +376,11 @@ export async function main(argv) {
|
|
|
333
376
|
}
|
|
334
377
|
}
|
|
335
378
|
for (const deKey of deKeys) {
|
|
336
|
-
const candidates = await findImportCandidates(dataDir, deKey
|
|
379
|
+
const candidates = await findImportCandidates(dataDir, deKey);
|
|
337
380
|
if (candidates.length === 0) {
|
|
338
|
-
console.error(
|
|
381
|
+
console.error(
|
|
382
|
+
`No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
|
|
383
|
+
);
|
|
339
384
|
return 1;
|
|
340
385
|
}
|
|
341
386
|
const filePath =
|
|
@@ -343,7 +388,6 @@ export async function main(argv) {
|
|
|
343
388
|
const n = await importFromFile(sdk, {
|
|
344
389
|
filePath,
|
|
345
390
|
deKey,
|
|
346
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
347
391
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
348
392
|
});
|
|
349
393
|
const rel = projectRelativePosix(projectRoot, filePath);
|
|
@@ -353,7 +397,9 @@ export async function main(argv) {
|
|
|
353
397
|
}
|
|
354
398
|
|
|
355
399
|
const fileList = values.file ?? [];
|
|
356
|
-
const keysFromFiles = fileList.map(
|
|
400
|
+
const keysFromFiles = fileList.map(
|
|
401
|
+
(fp) => parseExportBasename(path.basename(fp)).customerKey,
|
|
402
|
+
);
|
|
357
403
|
if (clear) {
|
|
358
404
|
await confirmClearBeforeImport({
|
|
359
405
|
deKeys: keysFromFiles,
|
|
@@ -370,7 +416,6 @@ export async function main(argv) {
|
|
|
370
416
|
const n = await importFromFile(sdk, {
|
|
371
417
|
filePath,
|
|
372
418
|
deKey: customerKey,
|
|
373
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
374
419
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
375
420
|
});
|
|
376
421
|
const rel = projectRelativePosix(projectRoot, filePath);
|
package/lib/config.mjs
CHANGED
|
@@ -67,20 +67,22 @@ export function resolveCredentialAndMid(mcdevrc, mcdevAuth, credentialName, buNa
|
|
|
67
67
|
if (midRaw === undefined || midRaw === null) {
|
|
68
68
|
throw new Error(`Unknown business unit "${buName}" under credential "${credentialName}"`);
|
|
69
69
|
}
|
|
70
|
-
const mid =
|
|
71
|
-
typeof midRaw === 'number' ? midRaw : Number.parseInt(String(midRaw), 10);
|
|
70
|
+
const mid = typeof midRaw === 'number' ? midRaw : Number.parseInt(String(midRaw), 10);
|
|
72
71
|
if (!Number.isInteger(mid)) {
|
|
73
|
-
throw new
|
|
72
|
+
throw new TypeError(`Invalid MID for ${credentialName}/${buName}: ${midRaw}`);
|
|
74
73
|
}
|
|
75
74
|
const authCred = mcdevAuth[credentialName];
|
|
76
75
|
if (!authCred?.client_id || !authCred?.client_secret || !authCred?.auth_url) {
|
|
77
|
-
throw new Error(
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Missing auth fields for credential "${credentialName}" in .mcdev-auth.json`,
|
|
78
|
+
);
|
|
78
79
|
}
|
|
79
80
|
return { mid, authCred };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
84
|
* Auth object for sfmc-sdk `Auth` / `SDK` constructor.
|
|
85
|
+
*
|
|
84
86
|
* @param {AuthCredential} authCred
|
|
85
87
|
* @param {number} mid
|
|
86
88
|
* @returns {import('sfmc-sdk').AuthObject}
|
|
@@ -93,3 +95,48 @@ export function buildSdkAuthObject(authCred, mid) {
|
|
|
93
95
|
account_id: mid,
|
|
94
96
|
};
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} DebugLogger
|
|
101
|
+
* @property {string} logPath - Absolute path to the log file
|
|
102
|
+
* @property {(text: string) => void} write - Append a line to the log file
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Options object for sfmc-sdk `SDK` constructor.
|
|
107
|
+
* When a logger is provided, includes event handlers to log API requests/responses to file.
|
|
108
|
+
*
|
|
109
|
+
* @param {DebugLogger|null} [logger] - Debug logger object, or null/undefined to disable logging
|
|
110
|
+
* @returns {import('sfmc-sdk').SdkOptions}
|
|
111
|
+
*/
|
|
112
|
+
export function buildSdkOptions(logger = null) {
|
|
113
|
+
/** @type {import('sfmc-sdk').SdkOptions} */
|
|
114
|
+
const options = { requestAttempts: 3 };
|
|
115
|
+
if (logger) {
|
|
116
|
+
options.eventHandlers = {
|
|
117
|
+
logRequest: (req) => {
|
|
118
|
+
const msg = structuredClone(req);
|
|
119
|
+
if (msg.headers?.Authorization) {
|
|
120
|
+
msg.headers.Authorization = 'Bearer *** TOKEN REMOVED ***';
|
|
121
|
+
}
|
|
122
|
+
logger.write(`API REQUEST >> ${msg.method?.toUpperCase() || 'GET'} ${msg.url}`);
|
|
123
|
+
if (msg.data) {
|
|
124
|
+
const body =
|
|
125
|
+
typeof msg.data === 'string' ? msg.data : JSON.stringify(msg.data, null, 2);
|
|
126
|
+
logger.write(`REQUEST BODY >> ${body}`);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
logResponse: (res) => {
|
|
130
|
+
logger.write(`API RESPONSE << ${res.status || res.statusCode || '(no status)'}`);
|
|
131
|
+
const body =
|
|
132
|
+
typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2);
|
|
133
|
+
const indentedBody = body
|
|
134
|
+
.split('\n')
|
|
135
|
+
.map((line) => ' ' + line)
|
|
136
|
+
.join('\n');
|
|
137
|
+
logger.write(`RESPONSE BODY <<\n${indentedBody}`);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return options;
|
|
142
|
+
}
|
package/lib/confirm-clear.mjs
CHANGED
|
@@ -10,10 +10,10 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
10
10
|
* @param {string[]} opts.deKeys
|
|
11
11
|
* @param {boolean} opts.acceptRiskFlag
|
|
12
12
|
* @param {boolean} opts.isTTY
|
|
13
|
-
* @param {CredBuTarget[]} [opts.targets]
|
|
13
|
+
* @param {CredBuTarget[]} [opts.targets] When present, renders a per-BU breakdown.
|
|
14
14
|
* @param {NodeJS.ReadableStream} [opts.stdin]
|
|
15
15
|
* @param {NodeJS.WritableStream} [opts.stdout]
|
|
16
|
-
* @returns {Promise
|
|
16
|
+
* @returns {Promise.<void>}
|
|
17
17
|
*/
|
|
18
18
|
export async function confirmClearBeforeImport(opts) {
|
|
19
19
|
const { deKeys, targets, acceptRiskFlag, isTTY } = opts;
|
|
@@ -25,30 +25,26 @@ export async function confirmClearBeforeImport(opts) {
|
|
|
25
25
|
if (!isTTY) {
|
|
26
26
|
throw new Error(
|
|
27
27
|
'Refusing to clear data in non-interactive mode without --i-accept-clear-data-risk. ' +
|
|
28
|
-
'All rows in the target Data Extension(s) would be permanently deleted.'
|
|
28
|
+
'All rows in the target Data Extension(s) would be permanently deleted.',
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
deKeys.map((k) => ` - ${k}\n`).join('') +
|
|
49
|
-
'This cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
|
|
50
|
-
'Type YES to continue, anything else to abort: ';
|
|
51
|
-
}
|
|
31
|
+
const msg =
|
|
32
|
+
targets && targets.length > 0
|
|
33
|
+
? '\n*** DANGER: CLEAR DATA ***\n' +
|
|
34
|
+
`This will permanently DELETE ALL ROWS across ${targets.length} Business Unit(s):\n\n` +
|
|
35
|
+
targets
|
|
36
|
+
.map(
|
|
37
|
+
({ credential, bu }) =>
|
|
38
|
+
` ${credential}/${bu}:\n` + deKeys.map((k) => ` - ${k}\n`).join(''),
|
|
39
|
+
)
|
|
40
|
+
.join('') +
|
|
41
|
+
'\nThis cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
|
|
42
|
+
'Type YES to continue, anything else to abort: '
|
|
43
|
+
: '\n*** DANGER: CLEAR DATA ***\n' +
|
|
44
|
+
'This will permanently DELETE ALL ROWS in:\n' +
|
|
45
|
+
deKeys.map((k) => ` - ${k}\n`).join('') +
|
|
46
|
+
'This cannot be undone. Enterprise 2.0 / admin / shared-DE rules may apply.\n' +
|
|
47
|
+
'Type YES to continue, anything else to abort: ';
|
|
52
48
|
stdout.write(msg);
|
|
53
49
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
54
50
|
try {
|
package/lib/cross-bu-import.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import path from 'node:path';
|
|
|
3
3
|
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
|
-
import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
|
|
6
|
+
import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
|
|
7
7
|
import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
|
|
8
|
+
import { formatFromExtension } from './file-resolve.mjs';
|
|
8
9
|
import { importRowsForDe } from './import-de.mjs';
|
|
9
10
|
import { readRowsFromFile } from './read-rows.mjs';
|
|
10
11
|
import { clearDataExtensionRows } from './clear-de.mjs';
|
|
@@ -25,7 +26,7 @@ import { buildExportBasename, filesystemSafeTimestamp, parseExportBasename } fro
|
|
|
25
26
|
* @param {string[]} opts.deKeys
|
|
26
27
|
* @param {NodeJS.ReadableStream} [opts.stdin]
|
|
27
28
|
* @param {NodeJS.WritableStream} [opts.stdout]
|
|
28
|
-
* @returns {Promise
|
|
29
|
+
* @returns {Promise.<boolean>}
|
|
29
30
|
*/
|
|
30
31
|
async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdout: stdoutStream }) {
|
|
31
32
|
const stdinSrc = stdinStream ?? input;
|
|
@@ -66,8 +67,8 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
|
|
|
66
67
|
* @param {import('./config.mjs').Mcdevrc} params.mcdevrc
|
|
67
68
|
* @param {Record<string, import('./config.mjs').AuthCredential>} params.mcdevAuth
|
|
68
69
|
* @param {string} [params.sourceCred] - API mode only
|
|
69
|
-
* @param {string} [params.sourceBu]
|
|
70
|
-
* @param {string[]} [params.deKeys]
|
|
70
|
+
* @param {string} [params.sourceBu] - API mode only
|
|
71
|
+
* @param {string[]} [params.deKeys] - API mode only
|
|
71
72
|
* @param {string[]} [params.filePaths] - File mode only; mutually exclusive with sourceCred/sourceBu/deKeys
|
|
72
73
|
* @param {CredBuTarget[]} params.targets
|
|
73
74
|
* @param {'csv'|'tsv'|'json'} params.format
|
|
@@ -76,9 +77,10 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
|
|
|
76
77
|
* @param {boolean} params.acceptRiskFlag
|
|
77
78
|
* @param {boolean} params.isTTY
|
|
78
79
|
* @param {boolean} [params.useGit] - stable snapshot basename (no timestamp)
|
|
79
|
-
* @param {
|
|
80
|
+
* @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
|
|
81
|
+
* @param {NodeJS.ReadableStream} [params.stdin] Override for testing
|
|
80
82
|
* @param {NodeJS.WritableStream} [params.stdout] Override for testing
|
|
81
|
-
* @returns {Promise
|
|
83
|
+
* @returns {Promise.<void>}
|
|
82
84
|
*/
|
|
83
85
|
export async function crossBuImport(params) {
|
|
84
86
|
const {
|
|
@@ -92,6 +94,7 @@ export async function crossBuImport(params) {
|
|
|
92
94
|
acceptRiskFlag,
|
|
93
95
|
isTTY,
|
|
94
96
|
useGit = false,
|
|
97
|
+
logger = null,
|
|
95
98
|
} = params;
|
|
96
99
|
const stdin = params.stdin;
|
|
97
100
|
const stdout = params.stdout;
|
|
@@ -123,9 +126,12 @@ export async function crossBuImport(params) {
|
|
|
123
126
|
let srcSdk = null;
|
|
124
127
|
if (!isFileBased) {
|
|
125
128
|
const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(
|
|
126
|
-
mcdevrc,
|
|
129
|
+
mcdevrc,
|
|
130
|
+
mcdevAuth,
|
|
131
|
+
params.sourceCred,
|
|
132
|
+
params.sourceBu,
|
|
127
133
|
);
|
|
128
|
-
srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid),
|
|
134
|
+
srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), buildSdkOptions(logger));
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
// Optional pre-import backup of target BU data
|
|
@@ -133,8 +139,13 @@ export async function crossBuImport(params) {
|
|
|
133
139
|
const doBackup = await offerPreExportBackup({ targets, deKeys, stdin, stdout });
|
|
134
140
|
if (doBackup) {
|
|
135
141
|
for (const { credential, bu } of targets) {
|
|
136
|
-
const { mid, authCred } = resolveCredentialAndMid(
|
|
137
|
-
|
|
142
|
+
const { mid, authCred } = resolveCredentialAndMid(
|
|
143
|
+
mcdevrc,
|
|
144
|
+
mcdevAuth,
|
|
145
|
+
credential,
|
|
146
|
+
bu,
|
|
147
|
+
);
|
|
148
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
138
149
|
for (const deKey of deKeys) {
|
|
139
150
|
const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
|
|
140
151
|
projectRoot,
|
|
@@ -158,22 +169,37 @@ export async function crossBuImport(params) {
|
|
|
158
169
|
|
|
159
170
|
// Load rows once per DE then fan out to every target
|
|
160
171
|
for (const deKey of deKeys) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
let rows;
|
|
173
|
+
if (isFileBased) {
|
|
174
|
+
const filePath = fileByDeKey.get(deKey);
|
|
175
|
+
const detectedFormat = formatFromExtension(filePath);
|
|
176
|
+
if (!detectedFormat) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Cannot determine format for file: ${filePath}. Use .csv, .tsv, or .json extension.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
rows = await readRowsFromFile(filePath, detectedFormat);
|
|
182
|
+
} else {
|
|
183
|
+
rows = await fetchAllRowObjects(srcSdk, deKey);
|
|
184
|
+
}
|
|
164
185
|
|
|
165
186
|
// Clear targets before import (rows already confirmed above)
|
|
166
187
|
if (clearBeforeImport) {
|
|
167
188
|
for (const { credential, bu } of targets) {
|
|
168
|
-
const { mid, authCred } = resolveCredentialAndMid(
|
|
169
|
-
|
|
189
|
+
const { mid, authCred } = resolveCredentialAndMid(
|
|
190
|
+
mcdevrc,
|
|
191
|
+
mcdevAuth,
|
|
192
|
+
credential,
|
|
193
|
+
bu,
|
|
194
|
+
);
|
|
195
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
170
196
|
await clearDataExtensionRows(tgtSdk.soap, deKey);
|
|
171
197
|
}
|
|
172
198
|
}
|
|
173
199
|
|
|
174
200
|
for (const { credential, bu } of targets) {
|
|
175
201
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
176
|
-
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
202
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
177
203
|
|
|
178
204
|
// Write a snapshot file in the target BU's data directory.
|
|
179
205
|
const dir = dataDirectoryForBu(projectRoot, credential, bu);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} DebugLogger
|
|
6
|
+
* @property {string} logPath - Absolute path to the log file
|
|
7
|
+
* @property {(text: string) => void} write - Append a line to the log file
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initialize a debug logger that writes API interactions to a timestamped log file.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} projectRoot - mcdev project root directory
|
|
14
|
+
* @param {string} version - mcdata version string
|
|
15
|
+
* @param {string[]} argv - Full process.argv array
|
|
16
|
+
* @returns {DebugLogger}
|
|
17
|
+
*/
|
|
18
|
+
export function initDebugLogger(projectRoot, version, argv) {
|
|
19
|
+
const logsDir = path.join(projectRoot, 'logs', 'data');
|
|
20
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// Timestamp with dots instead of colons for Windows filesystem compatibility
|
|
23
|
+
const ts = new Date().toISOString().replaceAll(':', '.');
|
|
24
|
+
const logPath = path.join(logsDir, `${ts}.log`);
|
|
25
|
+
|
|
26
|
+
// Reconstruct command line for header, quoting args with spaces
|
|
27
|
+
const command =
|
|
28
|
+
'mcdata ' +
|
|
29
|
+
argv
|
|
30
|
+
.slice(2)
|
|
31
|
+
.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg))
|
|
32
|
+
.join(' ');
|
|
33
|
+
|
|
34
|
+
// Write header
|
|
35
|
+
const header = `mcdata v${version}\nRan command: ${command}\n---\n`;
|
|
36
|
+
fs.writeFileSync(logPath, header, 'utf8');
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
logPath,
|
|
40
|
+
write: (text) => fs.appendFileSync(logPath, text + '\n', 'utf8'),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/lib/export-de.mjs
CHANGED
|
@@ -6,13 +6,22 @@ import { buildExportBasename, filesystemSafeTimestamp } from './filename.mjs';
|
|
|
6
6
|
import { dataDirectoryForBu } from './paths.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* @param {{
|
|
9
|
+
* @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
|
|
10
10
|
* @param {string} deKey
|
|
11
|
-
* @returns {Promise
|
|
11
|
+
* @returns {Promise.<object[]>}
|
|
12
12
|
*/
|
|
13
13
|
export async function fetchAllRowObjects(sdk, deKey) {
|
|
14
14
|
const basePath = rowsetGetPath(deKey);
|
|
15
|
-
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = await sdk.rest.getBulk(basePath, 2500);
|
|
18
|
+
} catch (ex) {
|
|
19
|
+
// this api endpoint won't return "items" if the dataExtension is empty
|
|
20
|
+
if (ex.message !== 'Could not find an array to iterate over') {
|
|
21
|
+
throw ex;
|
|
22
|
+
}
|
|
23
|
+
data = { items: [] };
|
|
24
|
+
}
|
|
16
25
|
const items = data.items ?? [];
|
|
17
26
|
const rows = [];
|
|
18
27
|
for (const item of items) {
|
|
@@ -35,14 +44,14 @@ export function serializeRows(rows, format, jsonPretty) {
|
|
|
35
44
|
const delimiter = format === 'tsv' ? '\t' : ',';
|
|
36
45
|
return stringify(rows, {
|
|
37
46
|
header: true,
|
|
38
|
-
quoted:
|
|
47
|
+
quoted: format === 'csv',
|
|
39
48
|
bom: true,
|
|
40
49
|
delimiter,
|
|
41
50
|
});
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/**
|
|
45
|
-
* @param {{
|
|
54
|
+
* @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
|
|
46
55
|
* @param {object} params
|
|
47
56
|
* @param {string} params.projectRoot
|
|
48
57
|
* @param {string} params.credentialName
|
|
@@ -51,10 +60,18 @@ export function serializeRows(rows, format, jsonPretty) {
|
|
|
51
60
|
* @param {'csv'|'tsv'|'json'} params.format
|
|
52
61
|
* @param {boolean} [params.jsonPretty]
|
|
53
62
|
* @param {boolean} [params.useGit]
|
|
54
|
-
* @returns {Promise
|
|
63
|
+
* @returns {Promise.<{path: string, rowCount: number}>}
|
|
55
64
|
*/
|
|
56
65
|
export async function exportDataExtensionToFile(sdk, params) {
|
|
57
|
-
const {
|
|
66
|
+
const {
|
|
67
|
+
projectRoot,
|
|
68
|
+
credentialName,
|
|
69
|
+
buName,
|
|
70
|
+
deKey,
|
|
71
|
+
format,
|
|
72
|
+
jsonPretty = false,
|
|
73
|
+
useGit = false,
|
|
74
|
+
} = params;
|
|
58
75
|
const rows = await fetchAllRowObjects(sdk, deKey);
|
|
59
76
|
const dir = dataDirectoryForBu(projectRoot, credentialName, buName);
|
|
60
77
|
await fs.mkdir(dir, { recursive: true });
|
package/lib/file-resolve.mjs
CHANGED
|
@@ -2,13 +2,37 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseExportBasename } from './filename.mjs';
|
|
4
4
|
|
|
5
|
+
/** Supported import/export file extensions */
|
|
6
|
+
const SUPPORTED_EXTENSIONS = ['csv', 'tsv', 'json'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Derive format from file extension.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @returns {'csv'|'tsv'|'json'|null}
|
|
13
|
+
*/
|
|
14
|
+
export function formatFromExtension(filePath) {
|
|
15
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
16
|
+
if (ext === 'csv') {
|
|
17
|
+
return 'csv';
|
|
18
|
+
}
|
|
19
|
+
if (ext === 'tsv') {
|
|
20
|
+
return 'tsv';
|
|
21
|
+
}
|
|
22
|
+
if (ext === 'json') {
|
|
23
|
+
return 'json';
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
/**
|
|
6
29
|
* Find export files under data dir matching the DE customer key and extension.
|
|
30
|
+
* When format is omitted, searches all supported extensions (csv, tsv, json).
|
|
7
31
|
*
|
|
8
32
|
* @param {string} dataDir
|
|
9
33
|
* @param {string} customerKey
|
|
10
|
-
* @param {'csv'|'tsv'|'json'} format
|
|
11
|
-
* @returns {Promise
|
|
34
|
+
* @param {'csv'|'tsv'|'json'} [format] - optional; if omitted, searches all extensions
|
|
35
|
+
* @returns {Promise.<string[]>} full paths
|
|
12
36
|
*/
|
|
13
37
|
export async function findImportCandidates(dataDir, customerKey, format) {
|
|
14
38
|
let entries;
|
|
@@ -17,14 +41,15 @@ export async function findImportCandidates(dataDir, customerKey, format) {
|
|
|
17
41
|
} catch {
|
|
18
42
|
return [];
|
|
19
43
|
}
|
|
20
|
-
const
|
|
44
|
+
const extensions = format ? [format] : SUPPORTED_EXTENSIONS;
|
|
21
45
|
const matches = [];
|
|
22
46
|
for (const ent of entries) {
|
|
23
47
|
if (!ent.isFile()) {
|
|
24
48
|
continue;
|
|
25
49
|
}
|
|
26
50
|
const name = ent.name;
|
|
27
|
-
|
|
51
|
+
const fileExt = path.extname(name).toLowerCase().slice(1);
|
|
52
|
+
if (!extensions.includes(fileExt)) {
|
|
28
53
|
continue;
|
|
29
54
|
}
|
|
30
55
|
try {
|
|
@@ -41,7 +66,7 @@ export async function findImportCandidates(dataDir, customerKey, format) {
|
|
|
41
66
|
|
|
42
67
|
/**
|
|
43
68
|
* @param {string[]} paths
|
|
44
|
-
* @returns {Promise
|
|
69
|
+
* @returns {Promise.<string>} path with newest mtime
|
|
45
70
|
*/
|
|
46
71
|
export async function pickLatestByMtime(paths) {
|
|
47
72
|
if (paths.length === 0) {
|
package/lib/filename.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mirrors sfmc-devtools `File.filterIllegalFilenames` / `reverseFilterIllegalFilenames`
|
|
3
3
|
* so export filenames stay consistent with mcdev retrieve-style paths.
|
|
4
|
+
*
|
|
4
5
|
* @see https://github.com/Accenture/sfmc-devtools (lib/util/file.js)
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -12,22 +13,20 @@ export const MCDATA_SEGMENT = '.mcdata.';
|
|
|
12
13
|
* @returns {string}
|
|
13
14
|
*/
|
|
14
15
|
export function filterIllegalFilenames(filename) {
|
|
15
|
-
return (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.join('@')
|
|
30
|
-
);
|
|
16
|
+
return encodeURIComponent(filename)
|
|
17
|
+
.replaceAll(/[*]/g, '_STAR_')
|
|
18
|
+
.split('%20')
|
|
19
|
+
.join(' ')
|
|
20
|
+
.split('%7B')
|
|
21
|
+
.join('{')
|
|
22
|
+
.split('%7D')
|
|
23
|
+
.join('}')
|
|
24
|
+
.split('%5B')
|
|
25
|
+
.join('[')
|
|
26
|
+
.split('%5D')
|
|
27
|
+
.join(']')
|
|
28
|
+
.split('%40')
|
|
29
|
+
.join('@');
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
/**
|
|
@@ -90,5 +89,7 @@ export function parseExportBasename(basename) {
|
|
|
90
89
|
};
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
throw new Error(
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Filename must contain ".mcdata." or end with ".mcdata" before the extension: ${basename}`,
|
|
94
|
+
);
|
|
94
95
|
}
|
package/lib/import-de.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chunkItemsForPayload } from './batch.mjs';
|
|
2
|
+
import { formatFromExtension } from './file-resolve.mjs';
|
|
2
3
|
import { resolveImportRoute } from './import-routes.mjs';
|
|
3
4
|
import { withRetry429 } from './retry.mjs';
|
|
4
5
|
import { readRowsFromFile } from './read-rows.mjs';
|
|
@@ -9,7 +10,7 @@ import { readRowsFromFile } from './read-rows.mjs';
|
|
|
9
10
|
* @param {string} params.deKey
|
|
10
11
|
* @param {object[]} params.rows
|
|
11
12
|
* @param {'upsert'|'insert'} params.mode
|
|
12
|
-
* @returns {Promise
|
|
13
|
+
* @returns {Promise.<number>} number of rows imported
|
|
13
14
|
*/
|
|
14
15
|
export async function importRowsForDe(sdk, params) {
|
|
15
16
|
const { deKey, rows, mode } = params;
|
|
@@ -19,9 +20,7 @@ export async function importRowsForDe(sdk, params) {
|
|
|
19
20
|
const p = route.path(deKey);
|
|
20
21
|
const body = { items: chunk };
|
|
21
22
|
await withRetry429(() =>
|
|
22
|
-
route.method === 'PUT'
|
|
23
|
-
? sdk.rest.put(p, body)
|
|
24
|
-
: sdk.rest.post(p, body)
|
|
23
|
+
route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
|
|
25
24
|
);
|
|
26
25
|
}
|
|
27
26
|
return rows.length;
|
|
@@ -32,12 +31,18 @@ export async function importRowsForDe(sdk, params) {
|
|
|
32
31
|
* @param {object} params
|
|
33
32
|
* @param {string} params.filePath
|
|
34
33
|
* @param {string} params.deKey - target DE customer key for API
|
|
35
|
-
* @param {'csv'|'tsv'|'json'} params.format
|
|
34
|
+
* @param {'csv'|'tsv'|'json'} [params.format] - optional; auto-detected from file extension if omitted
|
|
36
35
|
* @param {'upsert'|'insert'} params.mode
|
|
37
|
-
* @returns {Promise
|
|
36
|
+
* @returns {Promise.<number>} number of rows imported
|
|
38
37
|
*/
|
|
39
38
|
export async function importFromFile(sdk, params) {
|
|
40
|
-
const
|
|
39
|
+
const format = params.format || formatFromExtension(params.filePath);
|
|
40
|
+
if (!format) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Cannot determine format for file: ${params.filePath}. Use .csv, .tsv, or .json extension.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const rows = await readRowsFromFile(params.filePath, format);
|
|
41
46
|
return importRowsForDe(sdk, {
|
|
42
47
|
deKey: params.deKey,
|
|
43
48
|
rows,
|
package/lib/import-routes.mjs
CHANGED
package/lib/index.mjs
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
export { main } from './cli.mjs';
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
filterIllegalFilenames,
|
|
4
|
+
reverseFilterIllegalFilenames,
|
|
5
|
+
parseExportBasename,
|
|
6
|
+
} from './filename.mjs';
|
|
3
7
|
export { chunkItemsForPayload, DEFAULT_MAX_BODY_BYTES, MAX_OBJECTS_PER_BATCH } from './batch.mjs';
|
|
4
8
|
export { resolveImportRoute, rowsetGetPath, asyncDataExtensionRowsPath } from './import-routes.mjs';
|
|
5
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
loadMcdevProject,
|
|
11
|
+
parseCredBu,
|
|
12
|
+
resolveCredentialAndMid,
|
|
13
|
+
buildSdkAuthObject,
|
|
14
|
+
} from './config.mjs';
|
|
6
15
|
export { multiBuExport } from './multi-bu-export.mjs';
|
|
7
16
|
export { crossBuImport } from './cross-bu-import.mjs';
|
|
8
17
|
export { projectRelativePosix } from './paths.mjs';
|
package/lib/multi-bu-export.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import SDK from 'sfmc-sdk';
|
|
2
|
-
import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
|
|
2
|
+
import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
|
|
3
3
|
import { exportDataExtensionToFile } from './export-de.mjs';
|
|
4
4
|
import { projectRelativePosix } from './paths.mjs';
|
|
5
5
|
|
|
@@ -21,14 +21,25 @@ import { projectRelativePosix } from './paths.mjs';
|
|
|
21
21
|
* @param {'csv'|'tsv'|'json'} params.format
|
|
22
22
|
* @param {boolean} [params.jsonPretty]
|
|
23
23
|
* @param {boolean} [params.useGit]
|
|
24
|
-
* @
|
|
24
|
+
* @param {import('./config.mjs').DebugLogger|null} [params.logger]
|
|
25
|
+
* @returns {Promise.<string[]>} Paths of all written files
|
|
25
26
|
*/
|
|
26
|
-
export async function multiBuExport({
|
|
27
|
+
export async function multiBuExport({
|
|
28
|
+
projectRoot,
|
|
29
|
+
mcdevrc,
|
|
30
|
+
mcdevAuth,
|
|
31
|
+
sources,
|
|
32
|
+
deKeys,
|
|
33
|
+
format,
|
|
34
|
+
jsonPretty = false,
|
|
35
|
+
useGit = false,
|
|
36
|
+
logger = null,
|
|
37
|
+
}) {
|
|
27
38
|
/** @type {string[]} */
|
|
28
39
|
const exported = [];
|
|
29
40
|
for (const { credential, bu } of sources) {
|
|
30
41
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
31
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
42
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
32
43
|
for (const deKey of deKeys) {
|
|
33
44
|
const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
34
45
|
projectRoot,
|
package/lib/read-rows.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import csv from 'csv-parser';
|
|
|
4
4
|
/**
|
|
5
5
|
* @param {string} filePath
|
|
6
6
|
* @param {'csv'|'tsv'|'json'} format
|
|
7
|
-
* @returns {Promise
|
|
7
|
+
* @returns {Promise.<object[]>}
|
|
8
8
|
*/
|
|
9
9
|
export async function readRowsFromFile(filePath, format) {
|
|
10
10
|
if (format === 'json') {
|
|
@@ -26,7 +26,26 @@ export async function readRowsFromFile(filePath, format) {
|
|
|
26
26
|
csv({
|
|
27
27
|
separator: delimiter,
|
|
28
28
|
bom: true,
|
|
29
|
-
|
|
29
|
+
mapHeaders: ({ header }) => {
|
|
30
|
+
let h = header;
|
|
31
|
+
// Strip BOM if present (backup in case bom:true misses it)
|
|
32
|
+
if (h.codePointAt(0) === 0xFEFF) {
|
|
33
|
+
h = h.slice(1);
|
|
34
|
+
}
|
|
35
|
+
// Strip surrounding quotes if present (non-standard quoted TSV)
|
|
36
|
+
if (h.startsWith('"') && h.endsWith('"') && h.length >= 2) {
|
|
37
|
+
h = h.slice(1, -1);
|
|
38
|
+
}
|
|
39
|
+
return h;
|
|
40
|
+
},
|
|
41
|
+
mapValues: ({ value }) => {
|
|
42
|
+
// Strip surrounding quotes from values if present
|
|
43
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
44
|
+
return value.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
30
49
|
)
|
|
31
50
|
.on('data', (row) => rows.push(row))
|
|
32
51
|
.on('end', () => resolve(rows))
|
package/lib/retry.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { RestError } from 'sfmc-sdk/util';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @param {() => Promise
|
|
4
|
+
* @param {() => Promise.<any>} fn
|
|
5
5
|
* @param {object} [opts]
|
|
6
6
|
* @param {number} [opts.maxAttempts] default 5
|
|
7
|
-
* @returns {Promise
|
|
7
|
+
* @returns {Promise.<any>}
|
|
8
8
|
*/
|
|
9
9
|
export async function withRetry429(fn, opts = {}) {
|
|
10
10
|
const maxAttempts = opts.maxAttempts ?? 5;
|
|
@@ -14,26 +14,27 @@ export async function withRetry429(fn, opts = {}) {
|
|
|
14
14
|
attempt++;
|
|
15
15
|
try {
|
|
16
16
|
return await fn();
|
|
17
|
-
} catch (
|
|
18
|
-
const status =
|
|
19
|
-
const retryAfter =
|
|
17
|
+
} catch (ex) {
|
|
18
|
+
const status = ex instanceof RestError ? ex.response?.status : undefined;
|
|
19
|
+
const retryAfter =
|
|
20
|
+
ex instanceof RestError ? ex.response?.headers?.['retry-after'] : undefined;
|
|
20
21
|
if (status === 429 && attempt < maxAttempts) {
|
|
21
22
|
const wait =
|
|
22
|
-
retryAfter
|
|
23
|
-
?
|
|
24
|
-
: delayMs;
|
|
23
|
+
retryAfter === undefined
|
|
24
|
+
? delayMs
|
|
25
|
+
: Number.parseInt(String(retryAfter), 10) * 1000 || delayMs;
|
|
25
26
|
await sleep(wait);
|
|
26
27
|
delayMs = Math.min(delayMs * 2, 60_000);
|
|
27
28
|
continue;
|
|
28
29
|
}
|
|
29
|
-
throw
|
|
30
|
+
throw ex;
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* @param {number} ms
|
|
36
|
-
* @returns {Promise
|
|
37
|
+
* @returns {Promise.<void>}
|
|
37
38
|
*/
|
|
38
39
|
function sleep(ms) {
|
|
39
40
|
return new Promise((r) => setTimeout(r, ms));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfmc-dataloader",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.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",
|
|
@@ -44,6 +44,20 @@
|
|
|
44
44
|
"sfmc-sdk": "3.0.3"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
|
-
"test": "node --test test/**/*.test.js"
|
|
47
|
+
"test": "node --test test/**/*.test.js",
|
|
48
|
+
"lint": "eslint .",
|
|
49
|
+
"lint:fix": "eslint --fix .",
|
|
50
|
+
"format": "prettier --write .",
|
|
51
|
+
"format:check": "prettier --check ."
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@eslint/js": "^10.0.1",
|
|
55
|
+
"eslint": "^10.1.0",
|
|
56
|
+
"eslint-config-prettier": "^10.1.8",
|
|
57
|
+
"eslint-plugin-jsdoc": "^62.0.0",
|
|
58
|
+
"eslint-plugin-prettier": "^5.5.0",
|
|
59
|
+
"eslint-plugin-unicorn": "^64.0.0",
|
|
60
|
+
"globals": "^17.4.0",
|
|
61
|
+
"prettier": "^3.8.1"
|
|
48
62
|
}
|
|
49
63
|
}
|