sfmc-dataloader 2.0.2 → 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 +73 -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,6 +20,7 @@ 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() {
|
|
@@ -44,9 +46,10 @@ Usage:
|
|
|
44
46
|
Options:
|
|
45
47
|
--version Print version and exit
|
|
46
48
|
-p, --project <dir> mcdev project root (default: cwd)
|
|
47
|
-
--format <csv|tsv|json>
|
|
49
|
+
--format <csv|tsv|json> Export file format (default: csv); ignored for imports
|
|
48
50
|
--json-pretty Pretty-print JSON on export
|
|
49
51
|
--git Stable filenames: <key>.mcdata.<ext> (no timestamp)
|
|
52
|
+
--debug Write API requests/responses to ./logs/data/*.log
|
|
50
53
|
|
|
51
54
|
Import options:
|
|
52
55
|
--mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
|
|
@@ -61,8 +64,9 @@ Multi-BU options:
|
|
|
61
64
|
|
|
62
65
|
Notes:
|
|
63
66
|
Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
|
|
64
|
-
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).
|
|
65
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).
|
|
66
70
|
Cross-BU import stores a download file in each target BU's data directory.
|
|
67
71
|
|
|
68
72
|
Clear data warning:
|
|
@@ -73,7 +77,7 @@ Clear data warning:
|
|
|
73
77
|
|
|
74
78
|
/**
|
|
75
79
|
* @param {string[]} argv
|
|
76
|
-
* @returns {Promise
|
|
80
|
+
* @returns {Promise.<number>} exit code
|
|
77
81
|
*/
|
|
78
82
|
export async function main(argv) {
|
|
79
83
|
let values;
|
|
@@ -95,14 +99,15 @@ export async function main(argv) {
|
|
|
95
99
|
'clear-before-import': { type: 'boolean', default: false },
|
|
96
100
|
'i-accept-clear-data-risk': { type: 'boolean', default: false },
|
|
97
101
|
'json-pretty': { type: 'boolean', default: false },
|
|
102
|
+
debug: { type: 'boolean', default: false },
|
|
98
103
|
help: { type: 'boolean', short: 'h', default: false },
|
|
99
104
|
version: { type: 'boolean', default: false },
|
|
100
105
|
},
|
|
101
106
|
});
|
|
102
107
|
values = parsed.values;
|
|
103
108
|
positionals = parsed.positionals;
|
|
104
|
-
} catch (
|
|
105
|
-
console.error(
|
|
109
|
+
} catch (ex) {
|
|
110
|
+
console.error(ex.message);
|
|
106
111
|
printHelp();
|
|
107
112
|
return 1;
|
|
108
113
|
}
|
|
@@ -137,6 +142,15 @@ export async function main(argv) {
|
|
|
137
142
|
const hasTo = toFlags.length > 0;
|
|
138
143
|
const hasPositional = !!credBuRaw;
|
|
139
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
|
+
|
|
140
154
|
// ── export ──────────────────────────────────────────────────────────────
|
|
141
155
|
if (sub === 'export') {
|
|
142
156
|
if (hasTo) {
|
|
@@ -144,19 +158,23 @@ export async function main(argv) {
|
|
|
144
158
|
return 1;
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
const des = [
|
|
161
|
+
const des = [values.de ?? []].flat();
|
|
148
162
|
if (des.length === 0) {
|
|
149
163
|
console.error('export requires at least one --de <customerKey>');
|
|
150
164
|
return 1;
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
if (hasFrom && hasPositional) {
|
|
154
|
-
console.error(
|
|
168
|
+
console.error(
|
|
169
|
+
'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
|
|
170
|
+
);
|
|
155
171
|
return 1;
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
if (!hasFrom && !hasPositional) {
|
|
159
|
-
console.error(
|
|
175
|
+
console.error(
|
|
176
|
+
'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
|
|
177
|
+
);
|
|
160
178
|
printHelp();
|
|
161
179
|
return 1;
|
|
162
180
|
}
|
|
@@ -165,8 +183,8 @@ export async function main(argv) {
|
|
|
165
183
|
let sources;
|
|
166
184
|
try {
|
|
167
185
|
sources = fromFlags.map(parseCredBu);
|
|
168
|
-
} catch (
|
|
169
|
-
console.error(
|
|
186
|
+
} catch (ex) {
|
|
187
|
+
console.error(ex.message);
|
|
170
188
|
return 1;
|
|
171
189
|
}
|
|
172
190
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -179,6 +197,7 @@ export async function main(argv) {
|
|
|
179
197
|
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
180
198
|
jsonPretty: values['json-pretty'],
|
|
181
199
|
useGit,
|
|
200
|
+
logger,
|
|
182
201
|
});
|
|
183
202
|
return 0;
|
|
184
203
|
}
|
|
@@ -186,7 +205,7 @@ export async function main(argv) {
|
|
|
186
205
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
187
206
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
188
207
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
189
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
208
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
190
209
|
for (const deKey of des) {
|
|
191
210
|
const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
192
211
|
projectRoot,
|
|
@@ -217,19 +236,23 @@ export async function main(argv) {
|
|
|
217
236
|
// ── File-to-multi-BU import: --to + --file (no --from) ─────────────
|
|
218
237
|
if (hasTo && !hasFrom && values.file?.length > 0) {
|
|
219
238
|
if (hasPositional) {
|
|
220
|
-
console.error(
|
|
239
|
+
console.error(
|
|
240
|
+
'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
|
|
241
|
+
);
|
|
221
242
|
return 1;
|
|
222
243
|
}
|
|
223
244
|
if (values.de?.length > 0) {
|
|
224
|
-
console.error(
|
|
245
|
+
console.error(
|
|
246
|
+
'Cannot mix --de with --file in multi-target import. Use --file only.',
|
|
247
|
+
);
|
|
225
248
|
return 1;
|
|
226
249
|
}
|
|
227
250
|
const filePaths = values.file;
|
|
228
251
|
let targets;
|
|
229
252
|
try {
|
|
230
253
|
targets = toFlags.map(parseCredBu);
|
|
231
|
-
} catch (
|
|
232
|
-
console.error(
|
|
254
|
+
} catch (ex) {
|
|
255
|
+
console.error(ex.message);
|
|
233
256
|
return 1;
|
|
234
257
|
}
|
|
235
258
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -245,6 +268,7 @@ export async function main(argv) {
|
|
|
245
268
|
acceptRiskFlag: acceptRisk,
|
|
246
269
|
isTTY: process.stdin.isTTY === true,
|
|
247
270
|
useGit,
|
|
271
|
+
logger,
|
|
248
272
|
});
|
|
249
273
|
return 0;
|
|
250
274
|
}
|
|
@@ -252,26 +276,36 @@ export async function main(argv) {
|
|
|
252
276
|
// ── Cross-BU import (API mode): --from + --to + --de ────────────────
|
|
253
277
|
if (hasFrom || hasTo) {
|
|
254
278
|
if (hasPositional) {
|
|
255
|
-
console.error(
|
|
279
|
+
console.error(
|
|
280
|
+
'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
|
|
281
|
+
);
|
|
256
282
|
return 1;
|
|
257
283
|
}
|
|
258
284
|
if (!hasFrom) {
|
|
259
|
-
console.error(
|
|
285
|
+
console.error(
|
|
286
|
+
'--to requires --from <cred>/<bu> to specify the source Business Unit.',
|
|
287
|
+
);
|
|
260
288
|
return 1;
|
|
261
289
|
}
|
|
262
290
|
if (!hasTo) {
|
|
263
|
-
console.error(
|
|
291
|
+
console.error(
|
|
292
|
+
'--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
|
|
293
|
+
);
|
|
264
294
|
return 1;
|
|
265
295
|
}
|
|
266
296
|
if (fromFlags.length > 1) {
|
|
267
|
-
console.error(
|
|
297
|
+
console.error(
|
|
298
|
+
'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
|
|
299
|
+
);
|
|
268
300
|
return 1;
|
|
269
301
|
}
|
|
270
302
|
if (values.file?.length > 0) {
|
|
271
|
-
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
|
+
);
|
|
272
306
|
return 1;
|
|
273
307
|
}
|
|
274
|
-
const deKeys = [
|
|
308
|
+
const deKeys = [values.de ?? []].flat();
|
|
275
309
|
if (deKeys.length === 0) {
|
|
276
310
|
console.error('Cross-BU import requires at least one --de <customerKey>.');
|
|
277
311
|
return 1;
|
|
@@ -281,8 +315,8 @@ export async function main(argv) {
|
|
|
281
315
|
try {
|
|
282
316
|
sourceParsed = parseCredBu(fromFlags[0]);
|
|
283
317
|
targets = toFlags.map(parseCredBu);
|
|
284
|
-
} catch (
|
|
285
|
-
console.error(
|
|
318
|
+
} catch (ex) {
|
|
319
|
+
console.error(ex.message);
|
|
286
320
|
return 1;
|
|
287
321
|
}
|
|
288
322
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -300,13 +334,16 @@ export async function main(argv) {
|
|
|
300
334
|
acceptRiskFlag: acceptRisk,
|
|
301
335
|
isTTY: process.stdin.isTTY === true,
|
|
302
336
|
useGit,
|
|
337
|
+
logger,
|
|
303
338
|
});
|
|
304
339
|
return 0;
|
|
305
340
|
}
|
|
306
341
|
|
|
307
342
|
// ── Single-BU import (original behavior) ────────────────────────────
|
|
308
343
|
if (!hasPositional) {
|
|
309
|
-
console.error(
|
|
344
|
+
console.error(
|
|
345
|
+
'import requires either a positional <credential>/<bu> or --from/--to flags.',
|
|
346
|
+
);
|
|
310
347
|
printHelp();
|
|
311
348
|
return 1;
|
|
312
349
|
}
|
|
@@ -314,17 +351,19 @@ export async function main(argv) {
|
|
|
314
351
|
const hasDe = values.de?.length > 0;
|
|
315
352
|
const hasFile = values.file?.length > 0;
|
|
316
353
|
if (hasDe === hasFile) {
|
|
317
|
-
console.error(
|
|
354
|
+
console.error(
|
|
355
|
+
'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
|
|
356
|
+
);
|
|
318
357
|
return 1;
|
|
319
358
|
}
|
|
320
359
|
|
|
321
360
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
322
361
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
323
362
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
324
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
363
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
325
364
|
|
|
326
365
|
if (hasDe) {
|
|
327
|
-
const deKeys = [
|
|
366
|
+
const deKeys = [values.de ?? []].flat();
|
|
328
367
|
const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
|
|
329
368
|
if (clear) {
|
|
330
369
|
await confirmClearBeforeImport({
|
|
@@ -337,9 +376,11 @@ export async function main(argv) {
|
|
|
337
376
|
}
|
|
338
377
|
}
|
|
339
378
|
for (const deKey of deKeys) {
|
|
340
|
-
const candidates = await findImportCandidates(dataDir, deKey
|
|
379
|
+
const candidates = await findImportCandidates(dataDir, deKey);
|
|
341
380
|
if (candidates.length === 0) {
|
|
342
|
-
console.error(
|
|
381
|
+
console.error(
|
|
382
|
+
`No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
|
|
383
|
+
);
|
|
343
384
|
return 1;
|
|
344
385
|
}
|
|
345
386
|
const filePath =
|
|
@@ -347,7 +388,6 @@ export async function main(argv) {
|
|
|
347
388
|
const n = await importFromFile(sdk, {
|
|
348
389
|
filePath,
|
|
349
390
|
deKey,
|
|
350
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
351
391
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
352
392
|
});
|
|
353
393
|
const rel = projectRelativePosix(projectRoot, filePath);
|
|
@@ -357,7 +397,9 @@ export async function main(argv) {
|
|
|
357
397
|
}
|
|
358
398
|
|
|
359
399
|
const fileList = values.file ?? [];
|
|
360
|
-
const keysFromFiles = fileList.map(
|
|
400
|
+
const keysFromFiles = fileList.map(
|
|
401
|
+
(fp) => parseExportBasename(path.basename(fp)).customerKey,
|
|
402
|
+
);
|
|
361
403
|
if (clear) {
|
|
362
404
|
await confirmClearBeforeImport({
|
|
363
405
|
deKeys: keysFromFiles,
|
|
@@ -374,7 +416,6 @@ export async function main(argv) {
|
|
|
374
416
|
const n = await importFromFile(sdk, {
|
|
375
417
|
filePath,
|
|
376
418
|
deKey: customerKey,
|
|
377
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
378
419
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
379
420
|
});
|
|
380
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
|
}
|