sfmc-dataloader 2.0.2 → 2.2.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 +32 -16
- package/bin/mcdata.mjs +2 -2
- package/lib/clear-de.mjs +1 -1
- package/lib/cli.mjs +115 -32
- package/lib/config.mjs +51 -4
- package/lib/confirm-clear.mjs +20 -24
- package/lib/cross-bu-import.mjs +62 -35
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sfmc-dataloader
|
|
2
2
|
|
|
3
|
-
Command-line tool **`mcdata`** to export and import Salesforce Marketing Cloud Data Extension rows using the same project files as [mcdev](https://github.com/Accenture/sfmc-devtools) (`.mcdevrc.json`, `.mcdev-auth.json`) and [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
|
|
3
|
+
Command-line tool **`mcdata`** to export and import **Salesforce Marketing Cloud Engagement** Data Extension rows using the same project files as [mcdev](https://github.com/Accenture/sfmc-devtools) (`.mcdevrc.json`, `.mcdev-auth.json`) and [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
@@ -41,12 +41,12 @@ Creates one file per BU/DE combination using the same `.mcdata.` naming rules.
|
|
|
41
41
|
### Import — single BU
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
mcdata import MyCred/MyBU --de MyDE_CustomerKey --
|
|
44
|
+
mcdata import MyCred/MyBU --de MyDE_CustomerKey --mode upsert
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path).
|
|
48
48
|
|
|
49
|
-
Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key.
|
|
49
|
+
Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key. The file format is detected automatically from the file extension (`.csv`, `.tsv`, `.json`).
|
|
50
50
|
|
|
51
51
|
Import from explicit paths (the DE key is taken from the `.mcdata.` basename):
|
|
52
52
|
|
|
@@ -56,8 +56,8 @@ mcdata import MyCred/MyBU --file ./data/MyCred/MyBU/encoded%2Bkey.mcdata.2026-04
|
|
|
56
56
|
|
|
57
57
|
#### Upsert vs insert
|
|
58
58
|
|
|
59
|
-
- **Upsert** follows the platform
|
|
60
|
-
- **Insert** always adds new rows. Running import twice with insert can create **duplicate** rows if the same file is applied again—use upsert when keys are defined and you need
|
|
59
|
+
- **Upsert** follows the platform's usual behaviour: update when a primary key matches, otherwise insert. For Data Extensions **without** a primary key, upsert **will fail**; use **`--mode insert`** for those.
|
|
60
|
+
- **Insert** always adds new rows. Running import twice with insert can create **duplicate** rows if the same file is applied again—use upsert when primary keys are defined and you need to ensure repeated runs always have the same outcome.
|
|
61
61
|
|
|
62
62
|
### Import — one source BU into multiple target BUs (API mode)
|
|
63
63
|
|
|
@@ -67,7 +67,7 @@ Use `--from` (one source) and `--to` (repeatable targets) for a cross-BU import
|
|
|
67
67
|
mcdata import --from MyCred/Dev --to MyCred/QA --to MyCred/Prod --de Contact_DE
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
An optional pre-import backup exports current target BU data as **timestamped** files (backup filenames always include the timestamp, regardless of `--git`). Use `--backup-before-import` to run the backup without a prompt (CI-safe), or `--no-backup-before-import` to skip it entirely. When neither flag is provided the CLI prompts interactively (TTY only). A snapshot file is also written to each target BU's data directory as a record of what was imported.
|
|
71
71
|
|
|
72
72
|
### Import — local export files into multiple target BUs (file mode)
|
|
73
73
|
|
|
@@ -80,6 +80,20 @@ mcdata import --to MyCred/QA --to MyCred/Prod \
|
|
|
80
80
|
|
|
81
81
|
Multiple files can be supplied to push several DEs in one command. Pass **`--git`** on export if you rely on stable `*.mcdata.<ext>` names for snapshots in this flow.
|
|
82
82
|
|
|
83
|
+
### Backup target DE before import
|
|
84
|
+
|
|
85
|
+
Use `--backup-before-import` on any import command (single-BU or cross-BU) to export a timestamped snapshot of the current target DE rows before the import runs. The backup filename always includes a timestamp regardless of whether `--git` is set.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
mcdata import MyCred/MyBU --de MyKey --backup-before-import
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
In CI, combine with `--no-backup-before-import` to suppress any TTY prompt:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
mcdata import MyCred/MyBU --de MyKey --no-backup-before-import
|
|
95
|
+
```
|
|
96
|
+
|
|
83
97
|
### Clear all rows before import
|
|
84
98
|
|
|
85
99
|
**Dangerous:** removes every row in the target Data Extension(s) before uploading.
|
|
@@ -101,16 +115,18 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
|
|
|
101
115
|
|
|
102
116
|
## Options
|
|
103
117
|
|
|
104
|
-
| Option
|
|
105
|
-
|
|
106
|
-
| `-p, --project`
|
|
107
|
-
| `--format`
|
|
108
|
-
| `--git`
|
|
109
|
-
| `--mode`
|
|
110
|
-
| `--from <cred>/<bu>`
|
|
111
|
-
| `--to <cred>/<bu>`
|
|
112
|
-
| `--
|
|
113
|
-
| `--
|
|
118
|
+
| Option | Description |
|
|
119
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
120
|
+
| `-p, --project` | Project root (default: cwd) |
|
|
121
|
+
| `--format` | `csv` (default), `tsv`, or `json` — **export only**; import format is detected from the file extension |
|
|
122
|
+
| `--git` | Stable export filenames: `<key>.mcdata.<ext>` (no timestamp segment) |
|
|
123
|
+
| `--mode` | `upsert` (default) or `insert` — async bulk REST API only |
|
|
124
|
+
| `--from <cred>/<bu>` | Export: source BU (repeatable). Import API mode: single source BU (use with `--to` and `--de`) |
|
|
125
|
+
| `--to <cred>/<bu>` | Import: target BU (repeatable). API mode: use with `--from`/`--de`. File mode: use with `--file` (no `--from` needed) |
|
|
126
|
+
| `--backup-before-import` | Export target DE data as a timestamped backup before import (no prompt; always timestamped) |
|
|
127
|
+
| `--no-backup-before-import` | Skip the backup prompt even in interactive (TTY) sessions |
|
|
128
|
+
| `--clear-before-import` | SOAP `ClearData` before REST import |
|
|
129
|
+
| `--i-accept-clear-data-risk` | Non-interactive consent for clear |
|
|
114
130
|
|
|
115
131
|
Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
|
|
116
132
|
|
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,12 +46,15 @@ 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)
|
|
56
|
+
--backup-before-import Export target DE data as a timestamped backup before import (no prompt)
|
|
57
|
+
--no-backup-before-import Skip the backup prompt even in interactive (TTY) sessions
|
|
53
58
|
--clear-before-import SOAP ClearData before import (destructive; see below)
|
|
54
59
|
--i-accept-clear-data-risk Non-interactive acknowledgement for --clear-before-import
|
|
55
60
|
|
|
@@ -61,8 +66,9 @@ Multi-BU options:
|
|
|
61
66
|
|
|
62
67
|
Notes:
|
|
63
68
|
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).
|
|
69
|
+
Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
|
|
65
70
|
Import with --file parses the DE key from the basename (.mcdata. format).
|
|
71
|
+
Import format is auto-detected from file extension (.csv, .tsv, .json).
|
|
66
72
|
Cross-BU import stores a download file in each target BU's data directory.
|
|
67
73
|
|
|
68
74
|
Clear data warning:
|
|
@@ -73,7 +79,7 @@ Clear data warning:
|
|
|
73
79
|
|
|
74
80
|
/**
|
|
75
81
|
* @param {string[]} argv
|
|
76
|
-
* @returns {Promise
|
|
82
|
+
* @returns {Promise.<number>} exit code
|
|
77
83
|
*/
|
|
78
84
|
export async function main(argv) {
|
|
79
85
|
let values;
|
|
@@ -92,17 +98,20 @@ export async function main(argv) {
|
|
|
92
98
|
to: { type: 'string', multiple: true },
|
|
93
99
|
git: { type: 'boolean', default: false },
|
|
94
100
|
mode: { type: 'string' },
|
|
101
|
+
'backup-before-import': { type: 'boolean', default: false },
|
|
102
|
+
'no-backup-before-import': { type: 'boolean', default: false },
|
|
95
103
|
'clear-before-import': { type: 'boolean', default: false },
|
|
96
104
|
'i-accept-clear-data-risk': { type: 'boolean', default: false },
|
|
97
105
|
'json-pretty': { type: 'boolean', default: false },
|
|
106
|
+
debug: { type: 'boolean', default: false },
|
|
98
107
|
help: { type: 'boolean', short: 'h', default: false },
|
|
99
108
|
version: { type: 'boolean', default: false },
|
|
100
109
|
},
|
|
101
110
|
});
|
|
102
111
|
values = parsed.values;
|
|
103
112
|
positionals = parsed.positionals;
|
|
104
|
-
} catch (
|
|
105
|
-
console.error(
|
|
113
|
+
} catch (ex) {
|
|
114
|
+
console.error(ex.message);
|
|
106
115
|
printHelp();
|
|
107
116
|
return 1;
|
|
108
117
|
}
|
|
@@ -130,6 +139,12 @@ export async function main(argv) {
|
|
|
130
139
|
return 1;
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
const backupBeforeImport = values['backup-before-import']
|
|
143
|
+
? true
|
|
144
|
+
: values['no-backup-before-import']
|
|
145
|
+
? false
|
|
146
|
+
: undefined;
|
|
147
|
+
|
|
133
148
|
const useGit = values.git === true;
|
|
134
149
|
const fromFlags = values.from ?? [];
|
|
135
150
|
const toFlags = values.to ?? [];
|
|
@@ -137,6 +152,15 @@ export async function main(argv) {
|
|
|
137
152
|
const hasTo = toFlags.length > 0;
|
|
138
153
|
const hasPositional = !!credBuRaw;
|
|
139
154
|
|
|
155
|
+
// Initialize debug logger if --debug flag is set
|
|
156
|
+
/** @type {import('./debug-logger.mjs').DebugLogger|null} */
|
|
157
|
+
const logger = values.debug
|
|
158
|
+
? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
|
|
159
|
+
: null;
|
|
160
|
+
if (logger) {
|
|
161
|
+
console.error(`Debug log: ${projectRelativePosix(projectRoot, logger.logPath)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
140
164
|
// ── export ──────────────────────────────────────────────────────────────
|
|
141
165
|
if (sub === 'export') {
|
|
142
166
|
if (hasTo) {
|
|
@@ -144,19 +168,23 @@ export async function main(argv) {
|
|
|
144
168
|
return 1;
|
|
145
169
|
}
|
|
146
170
|
|
|
147
|
-
const des = [
|
|
171
|
+
const des = [values.de ?? []].flat();
|
|
148
172
|
if (des.length === 0) {
|
|
149
173
|
console.error('export requires at least one --de <customerKey>');
|
|
150
174
|
return 1;
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
if (hasFrom && hasPositional) {
|
|
154
|
-
console.error(
|
|
178
|
+
console.error(
|
|
179
|
+
'Cannot mix a positional <credential>/<bu> with --from. Use one or the other.',
|
|
180
|
+
);
|
|
155
181
|
return 1;
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
if (!hasFrom && !hasPositional) {
|
|
159
|
-
console.error(
|
|
185
|
+
console.error(
|
|
186
|
+
'export requires either a positional <credential>/<bu> or at least one --from <cred>/<bu>.',
|
|
187
|
+
);
|
|
160
188
|
printHelp();
|
|
161
189
|
return 1;
|
|
162
190
|
}
|
|
@@ -165,8 +193,8 @@ export async function main(argv) {
|
|
|
165
193
|
let sources;
|
|
166
194
|
try {
|
|
167
195
|
sources = fromFlags.map(parseCredBu);
|
|
168
|
-
} catch (
|
|
169
|
-
console.error(
|
|
196
|
+
} catch (ex) {
|
|
197
|
+
console.error(ex.message);
|
|
170
198
|
return 1;
|
|
171
199
|
}
|
|
172
200
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -179,6 +207,7 @@ export async function main(argv) {
|
|
|
179
207
|
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
180
208
|
jsonPretty: values['json-pretty'],
|
|
181
209
|
useGit,
|
|
210
|
+
logger,
|
|
182
211
|
});
|
|
183
212
|
return 0;
|
|
184
213
|
}
|
|
@@ -186,7 +215,7 @@ export async function main(argv) {
|
|
|
186
215
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
187
216
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
188
217
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
189
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
218
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
190
219
|
for (const deKey of des) {
|
|
191
220
|
const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
192
221
|
projectRoot,
|
|
@@ -217,19 +246,23 @@ export async function main(argv) {
|
|
|
217
246
|
// ── File-to-multi-BU import: --to + --file (no --from) ─────────────
|
|
218
247
|
if (hasTo && !hasFrom && values.file?.length > 0) {
|
|
219
248
|
if (hasPositional) {
|
|
220
|
-
console.error(
|
|
249
|
+
console.error(
|
|
250
|
+
'Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.',
|
|
251
|
+
);
|
|
221
252
|
return 1;
|
|
222
253
|
}
|
|
223
254
|
if (values.de?.length > 0) {
|
|
224
|
-
console.error(
|
|
255
|
+
console.error(
|
|
256
|
+
'Cannot mix --de with --file in multi-target import. Use --file only.',
|
|
257
|
+
);
|
|
225
258
|
return 1;
|
|
226
259
|
}
|
|
227
260
|
const filePaths = values.file;
|
|
228
261
|
let targets;
|
|
229
262
|
try {
|
|
230
263
|
targets = toFlags.map(parseCredBu);
|
|
231
|
-
} catch (
|
|
232
|
-
console.error(
|
|
264
|
+
} catch (ex) {
|
|
265
|
+
console.error(ex.message);
|
|
233
266
|
return 1;
|
|
234
267
|
}
|
|
235
268
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -241,10 +274,12 @@ export async function main(argv) {
|
|
|
241
274
|
targets,
|
|
242
275
|
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
243
276
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
277
|
+
backupBeforeImport,
|
|
244
278
|
clearBeforeImport: clear,
|
|
245
279
|
acceptRiskFlag: acceptRisk,
|
|
246
280
|
isTTY: process.stdin.isTTY === true,
|
|
247
281
|
useGit,
|
|
282
|
+
logger,
|
|
248
283
|
});
|
|
249
284
|
return 0;
|
|
250
285
|
}
|
|
@@ -252,26 +287,36 @@ export async function main(argv) {
|
|
|
252
287
|
// ── Cross-BU import (API mode): --from + --to + --de ────────────────
|
|
253
288
|
if (hasFrom || hasTo) {
|
|
254
289
|
if (hasPositional) {
|
|
255
|
-
console.error(
|
|
290
|
+
console.error(
|
|
291
|
+
'Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.',
|
|
292
|
+
);
|
|
256
293
|
return 1;
|
|
257
294
|
}
|
|
258
295
|
if (!hasFrom) {
|
|
259
|
-
console.error(
|
|
296
|
+
console.error(
|
|
297
|
+
'--to requires --from <cred>/<bu> to specify the source Business Unit.',
|
|
298
|
+
);
|
|
260
299
|
return 1;
|
|
261
300
|
}
|
|
262
301
|
if (!hasTo) {
|
|
263
|
-
console.error(
|
|
302
|
+
console.error(
|
|
303
|
+
'--from requires at least one --to <cred>/<bu> to specify target Business Unit(s).',
|
|
304
|
+
);
|
|
264
305
|
return 1;
|
|
265
306
|
}
|
|
266
307
|
if (fromFlags.length > 1) {
|
|
267
|
-
console.error(
|
|
308
|
+
console.error(
|
|
309
|
+
'import accepts exactly one --from <cred>/<bu> (use multiple --to for multiple targets).',
|
|
310
|
+
);
|
|
268
311
|
return 1;
|
|
269
312
|
}
|
|
270
313
|
if (values.file?.length > 0) {
|
|
271
|
-
console.error(
|
|
314
|
+
console.error(
|
|
315
|
+
'--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).',
|
|
316
|
+
);
|
|
272
317
|
return 1;
|
|
273
318
|
}
|
|
274
|
-
const deKeys = [
|
|
319
|
+
const deKeys = [values.de ?? []].flat();
|
|
275
320
|
if (deKeys.length === 0) {
|
|
276
321
|
console.error('Cross-BU import requires at least one --de <customerKey>.');
|
|
277
322
|
return 1;
|
|
@@ -281,8 +326,8 @@ export async function main(argv) {
|
|
|
281
326
|
try {
|
|
282
327
|
sourceParsed = parseCredBu(fromFlags[0]);
|
|
283
328
|
targets = toFlags.map(parseCredBu);
|
|
284
|
-
} catch (
|
|
285
|
-
console.error(
|
|
329
|
+
} catch (ex) {
|
|
330
|
+
console.error(ex.message);
|
|
286
331
|
return 1;
|
|
287
332
|
}
|
|
288
333
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
@@ -296,17 +341,21 @@ export async function main(argv) {
|
|
|
296
341
|
deKeys,
|
|
297
342
|
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
298
343
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
344
|
+
backupBeforeImport,
|
|
299
345
|
clearBeforeImport: clear,
|
|
300
346
|
acceptRiskFlag: acceptRisk,
|
|
301
347
|
isTTY: process.stdin.isTTY === true,
|
|
302
348
|
useGit,
|
|
349
|
+
logger,
|
|
303
350
|
});
|
|
304
351
|
return 0;
|
|
305
352
|
}
|
|
306
353
|
|
|
307
354
|
// ── Single-BU import (original behavior) ────────────────────────────
|
|
308
355
|
if (!hasPositional) {
|
|
309
|
-
console.error(
|
|
356
|
+
console.error(
|
|
357
|
+
'import requires either a positional <credential>/<bu> or --from/--to flags.',
|
|
358
|
+
);
|
|
310
359
|
printHelp();
|
|
311
360
|
return 1;
|
|
312
361
|
}
|
|
@@ -314,18 +363,35 @@ export async function main(argv) {
|
|
|
314
363
|
const hasDe = values.de?.length > 0;
|
|
315
364
|
const hasFile = values.file?.length > 0;
|
|
316
365
|
if (hasDe === hasFile) {
|
|
317
|
-
console.error(
|
|
366
|
+
console.error(
|
|
367
|
+
'import requires exactly one of: repeated --de <key> OR repeated --file <path>',
|
|
368
|
+
);
|
|
318
369
|
return 1;
|
|
319
370
|
}
|
|
320
371
|
|
|
321
372
|
const { credential, bu } = parseCredBu(credBuRaw);
|
|
322
373
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
323
374
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
324
|
-
const sdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
375
|
+
const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
325
376
|
|
|
326
377
|
if (hasDe) {
|
|
327
|
-
const deKeys = [
|
|
378
|
+
const deKeys = [values.de ?? []].flat();
|
|
328
379
|
const dataDir = dataDirectoryForBu(projectRoot, credential, bu);
|
|
380
|
+
if (backupBeforeImport === true) {
|
|
381
|
+
for (const deKey of deKeys) {
|
|
382
|
+
const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
383
|
+
projectRoot,
|
|
384
|
+
credentialName: credential,
|
|
385
|
+
buName: bu,
|
|
386
|
+
deKey,
|
|
387
|
+
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
388
|
+
useGit: false,
|
|
389
|
+
});
|
|
390
|
+
console.error(
|
|
391
|
+
`Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
329
395
|
if (clear) {
|
|
330
396
|
await confirmClearBeforeImport({
|
|
331
397
|
deKeys,
|
|
@@ -337,9 +403,11 @@ export async function main(argv) {
|
|
|
337
403
|
}
|
|
338
404
|
}
|
|
339
405
|
for (const deKey of deKeys) {
|
|
340
|
-
const candidates = await findImportCandidates(dataDir, deKey
|
|
406
|
+
const candidates = await findImportCandidates(dataDir, deKey);
|
|
341
407
|
if (candidates.length === 0) {
|
|
342
|
-
console.error(
|
|
408
|
+
console.error(
|
|
409
|
+
`No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
|
|
410
|
+
);
|
|
343
411
|
return 1;
|
|
344
412
|
}
|
|
345
413
|
const filePath =
|
|
@@ -347,7 +415,6 @@ export async function main(argv) {
|
|
|
347
415
|
const n = await importFromFile(sdk, {
|
|
348
416
|
filePath,
|
|
349
417
|
deKey,
|
|
350
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
351
418
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
352
419
|
});
|
|
353
420
|
const rel = projectRelativePosix(projectRoot, filePath);
|
|
@@ -357,7 +424,24 @@ export async function main(argv) {
|
|
|
357
424
|
}
|
|
358
425
|
|
|
359
426
|
const fileList = values.file ?? [];
|
|
360
|
-
const keysFromFiles = fileList.map(
|
|
427
|
+
const keysFromFiles = fileList.map(
|
|
428
|
+
(fp) => parseExportBasename(path.basename(fp)).customerKey,
|
|
429
|
+
);
|
|
430
|
+
if (backupBeforeImport === true) {
|
|
431
|
+
for (const deKey of keysFromFiles) {
|
|
432
|
+
const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
|
|
433
|
+
projectRoot,
|
|
434
|
+
credentialName: credential,
|
|
435
|
+
buName: bu,
|
|
436
|
+
deKey,
|
|
437
|
+
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
438
|
+
useGit: false,
|
|
439
|
+
});
|
|
440
|
+
console.error(
|
|
441
|
+
`Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
361
445
|
if (clear) {
|
|
362
446
|
await confirmClearBeforeImport({
|
|
363
447
|
deKeys: keysFromFiles,
|
|
@@ -374,7 +458,6 @@ export async function main(argv) {
|
|
|
374
458
|
const n = await importFromFile(sdk, {
|
|
375
459
|
filePath,
|
|
376
460
|
deKey: customerKey,
|
|
377
|
-
format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
|
|
378
461
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
379
462
|
});
|
|
380
463
|
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,19 +67,21 @@ 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
|
|
74
75
|
* @param {'upsert'|'insert'} params.mode
|
|
76
|
+
* @param {boolean} [params.backupBeforeImport] - true=always backup, false=never backup, undefined=TTY prompt
|
|
75
77
|
* @param {boolean} params.clearBeforeImport
|
|
76
78
|
* @param {boolean} params.acceptRiskFlag
|
|
77
79
|
* @param {boolean} params.isTTY
|
|
78
|
-
* @param {boolean} [params.useGit] -
|
|
79
|
-
* @param {
|
|
80
|
+
* @param {boolean} [params.useGit] - accepted for API compatibility but ignored; snapshot files always use a timestamped name
|
|
81
|
+
* @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
|
|
82
|
+
* @param {NodeJS.ReadableStream} [params.stdin] Override for testing
|
|
80
83
|
* @param {NodeJS.WritableStream} [params.stdout] Override for testing
|
|
81
|
-
* @returns {Promise
|
|
84
|
+
* @returns {Promise.<void>}
|
|
82
85
|
*/
|
|
83
86
|
export async function crossBuImport(params) {
|
|
84
87
|
const {
|
|
@@ -88,10 +91,11 @@ export async function crossBuImport(params) {
|
|
|
88
91
|
targets,
|
|
89
92
|
format,
|
|
90
93
|
mode,
|
|
94
|
+
backupBeforeImport,
|
|
91
95
|
clearBeforeImport,
|
|
92
96
|
acceptRiskFlag,
|
|
93
97
|
isTTY,
|
|
94
|
-
|
|
98
|
+
logger = null,
|
|
95
99
|
} = params;
|
|
96
100
|
const stdin = params.stdin;
|
|
97
101
|
const stdout = params.stdout;
|
|
@@ -123,30 +127,38 @@ export async function crossBuImport(params) {
|
|
|
123
127
|
let srcSdk = null;
|
|
124
128
|
if (!isFileBased) {
|
|
125
129
|
const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(
|
|
126
|
-
mcdevrc,
|
|
130
|
+
mcdevrc,
|
|
131
|
+
mcdevAuth,
|
|
132
|
+
params.sourceCred,
|
|
133
|
+
params.sourceBu,
|
|
127
134
|
);
|
|
128
|
-
srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid),
|
|
135
|
+
srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), buildSdkOptions(logger));
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
// Optional pre-import backup of target BU data
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
const shouldBackup =
|
|
140
|
+
backupBeforeImport === true
|
|
141
|
+
? true
|
|
142
|
+
: backupBeforeImport === false
|
|
143
|
+
? false
|
|
144
|
+
: isTTY
|
|
145
|
+
? await offerPreExportBackup({ targets, deKeys, stdin, stdout })
|
|
146
|
+
: false;
|
|
147
|
+
if (shouldBackup) {
|
|
148
|
+
for (const { credential, bu } of targets) {
|
|
149
|
+
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
150
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
151
|
+
for (const deKey of deKeys) {
|
|
152
|
+
const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
|
|
153
|
+
projectRoot,
|
|
154
|
+
credentialName: credential,
|
|
155
|
+
buName: bu,
|
|
156
|
+
deKey,
|
|
157
|
+
format,
|
|
158
|
+
useGit: false,
|
|
159
|
+
});
|
|
160
|
+
const rel = projectRelativePosix(projectRoot, outPath);
|
|
161
|
+
console.error(`Backup export: ${rel} (${rowCount} rows)`);
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
164
|
}
|
|
@@ -158,28 +170,43 @@ export async function crossBuImport(params) {
|
|
|
158
170
|
|
|
159
171
|
// Load rows once per DE then fan out to every target
|
|
160
172
|
for (const deKey of deKeys) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
let rows;
|
|
174
|
+
if (isFileBased) {
|
|
175
|
+
const filePath = fileByDeKey.get(deKey);
|
|
176
|
+
const detectedFormat = formatFromExtension(filePath);
|
|
177
|
+
if (!detectedFormat) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Cannot determine format for file: ${filePath}. Use .csv, .tsv, or .json extension.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
rows = await readRowsFromFile(filePath, detectedFormat);
|
|
183
|
+
} else {
|
|
184
|
+
rows = await fetchAllRowObjects(srcSdk, deKey);
|
|
185
|
+
}
|
|
164
186
|
|
|
165
187
|
// Clear targets before import (rows already confirmed above)
|
|
166
188
|
if (clearBeforeImport) {
|
|
167
189
|
for (const { credential, bu } of targets) {
|
|
168
|
-
const { mid, authCred } = resolveCredentialAndMid(
|
|
169
|
-
|
|
190
|
+
const { mid, authCred } = resolveCredentialAndMid(
|
|
191
|
+
mcdevrc,
|
|
192
|
+
mcdevAuth,
|
|
193
|
+
credential,
|
|
194
|
+
bu,
|
|
195
|
+
);
|
|
196
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
170
197
|
await clearDataExtensionRows(tgtSdk.soap, deKey);
|
|
171
198
|
}
|
|
172
199
|
}
|
|
173
200
|
|
|
174
201
|
for (const { credential, bu } of targets) {
|
|
175
202
|
const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
|
|
176
|
-
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid),
|
|
203
|
+
const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
|
|
177
204
|
|
|
178
205
|
// Write a snapshot file in the target BU's data directory.
|
|
179
206
|
const dir = dataDirectoryForBu(projectRoot, credential, bu);
|
|
180
207
|
await fs.mkdir(dir, { recursive: true });
|
|
181
208
|
const ts = filesystemSafeTimestamp();
|
|
182
|
-
const basename = buildExportBasename(deKey, ts, format,
|
|
209
|
+
const basename = buildExportBasename(deKey, ts, format, false);
|
|
183
210
|
const snapshotPath = path.join(dir, basename);
|
|
184
211
|
await fs.writeFile(snapshotPath, serializeRows(rows, format, false), 'utf8');
|
|
185
212
|
const snapRel = projectRelativePosix(projectRoot, snapshotPath);
|
|
@@ -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.2.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
|
|
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
|
}
|