sfmc-dataloader 1.1.0 → 2.0.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 CHANGED
@@ -24,7 +24,9 @@ Run from your mcdev project root (where `.mcdevrc.json` lives).
24
24
  mcdata export MyCred/MyBU --de MyDE_CustomerKey --format csv
25
25
  ```
26
26
 
27
- Writes to `./data/MyCred/MyBU/<encodedKey>+MCDATA+<timestamp>.csv` (TSV/JSON with `--format`).
27
+ Writes to `./data/MyCred/MyBU/<encodedKey>.mcdata.<timestamp>.csv` (TSV/JSON with `--format`). Timestamps use the same filesystem-safe ISO format as before.
28
+
29
+ Use **`--git`** for a stable name without a timestamp: `<encodedKey>.mcdata.csv` (useful for version control).
28
30
 
29
31
  ### Export — multiple BUs at once
30
32
 
@@ -34,49 +36,62 @@ Use `--from` (repeatable) instead of the positional argument to export the same
34
36
  mcdata export --from MyCred/Dev --from MyCred/QA --de Contact_DE --de Order_DE
35
37
  ```
36
38
 
37
- Creates one timestamped file per BU/DE combination:
38
-
39
- ```
40
- ./data/MyCred/Dev/Contact_DE+MCDATA+<timestamp>.csv
41
- ./data/MyCred/Dev/Order_DE+MCDATA+<timestamp>.csv
42
- ./data/MyCred/QA/Contact_DE+MCDATA+<timestamp>.csv
43
- ./data/MyCred/QA/Order_DE+MCDATA+<timestamp>.csv
44
- ```
39
+ Creates one file per BU/DE combination using the same `.mcdata.` naming rules.
45
40
 
46
41
  ### Import — single BU
47
42
 
48
43
  ```bash
49
- mcdata import MyCred/MyBU --de MyDE_CustomerKey --format csv --api async --mode upsert
44
+ mcdata import MyCred/MyBU --de MyDE_CustomerKey --format csv --mode upsert
50
45
  ```
51
46
 
47
+ Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path).
48
+
52
49
  Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key.
53
50
 
54
- Import from explicit paths (DE key is recovered from the `+MCDATA+` filename):
51
+ Import from explicit paths (the DE key is taken from the `.mcdata.` basename):
55
52
 
56
53
  ```bash
57
- mcdata import MyCred/MyBU --file ./data/MyCred/MyBU/encoded%2Bkey+MCDATA+2026-04-06T12-00-00.000Z.csv
54
+ mcdata import MyCred/MyBU --file ./data/MyCred/MyBU/encoded%2Bkey.mcdata.2026-04-06T12-00-00.000Z.csv
58
55
  ```
59
56
 
60
- ### Import one source BU into multiple target BUs
57
+ #### Upsert vs insert
58
+
59
+ - **Upsert** follows the platform’s usual behaviour: update when a primary key matches, otherwise insert. For Data Extensions **without** a primary key, upsert may not behave as expected; prefer **`--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 keys are defined and you need idempotent runs.
61
61
 
62
- Use `--from` (one source) and `--to` (repeatable targets) for a cross-BU import:
62
+ ### Import one source BU into multiple target BUs (API mode)
63
+
64
+ Use `--from` (one source) and `--to` (repeatable targets) for a cross-BU import where rows are fetched live from the source BU:
63
65
 
64
66
  ```bash
65
67
  mcdata import --from MyCred/Dev --to MyCred/QA --to MyCred/Prod --de Contact_DE
66
68
  ```
67
69
 
68
- Before the import starts you will be offered the option to export the current data from each target BU as a timestamped backup. A timestamped download file is also written to each target BU's data directory so there is a traceable record of exactly what was imported.
70
+ Before the import starts you will be offered the option to export the current data from each target BU as a timestamped backup. A download file is also written to each target BU's data directory so there is a traceable record of exactly what was imported.
71
+
72
+ ### Import — local export files into multiple target BUs (file mode)
73
+
74
+ Use `--to` (repeatable targets) and `--file` (repeatable file paths) to push previously exported data files to multiple BUs without connecting to a source BU. The DE customer key is derived from each filename automatically:
75
+
76
+ ```bash
77
+ mcdata import --to MyCred/QA --to MyCred/Prod \
78
+ --file ./data/MyCred/Dev/Contact_DE.mcdata.2026-04-08T10-00-00.000Z.csv
79
+ ```
80
+
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.
69
82
 
70
83
  ### Clear all rows before import
71
84
 
72
85
  **Dangerous:** removes every row in the target Data Extension(s) before uploading.
73
86
 
74
87
  Single-BU:
88
+
75
89
  ```bash
76
90
  mcdata import MyCred/MyBU --de MyKey --clear-before-import
77
91
  ```
78
92
 
79
93
  Cross-BU (warning lists every affected BU):
94
+
80
95
  ```bash
81
96
  mcdata import --from MyCred/Dev --to MyCred/QA --to MyCred/Prod \
82
97
  --de Contact_DE --clear-before-import
@@ -90,13 +105,15 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
90
105
  |--------|-------------|
91
106
  | `-p, --project` | Project root (default: cwd) |
92
107
  | `--format` | `csv` (default), `tsv`, or `json` |
93
- | `--api` | `async` (default) or `sync` |
94
- | `--mode` | `upsert` (default), `insert` and `update` require `--api sync` |
95
- | `--from <cred>/<bu>` | Export: source BU (repeatable). Import: single source BU (use with `--to`) |
96
- | `--to <cred>/<bu>` | Import: target BU (repeatable for multiple targets) |
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) |
97
112
  | `--clear-before-import` | SOAP `ClearData` before REST import |
98
113
  | `--i-accept-clear-data-risk` | Non-interactive consent for clear |
99
114
 
115
+ Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
116
+
100
117
  ## License
101
118
 
102
119
  MIT — Author: Jörn Berkefeld
package/lib/cli.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  resolveCredentialAndMid,
9
9
  buildSdkAuthObject,
10
10
  } from './config.mjs';
11
- import { dataDirectoryForBu } from './paths.mjs';
11
+ import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
12
12
  import { exportDataExtensionToFile } from './export-de.mjs';
13
13
  import { findImportCandidates, pickLatestByMtime } from './file-resolve.mjs';
14
14
  import { parseExportBasename } from './filename.mjs';
@@ -26,28 +26,30 @@ Usage:
26
26
  mcdata export --from <cred>/<bu> [--from <cred>/<bu> ...] --de <key> [--de <key> ...] [options]
27
27
  mcdata import <credential>/<bu> (--de <key> ... | --file <path> ...) [options]
28
28
  mcdata import --from <cred>/<bu> --to <cred>/<bu> [--to <cred>/<bu> ...] --de <key> ... [options]
29
+ mcdata import --to <cred>/<bu> [--to <cred>/<bu> ...] --file <path> [--file <path> ...] [options]
29
30
 
30
31
  Options:
31
32
  -p, --project <dir> mcdev project root (default: cwd)
32
33
  --format <csv|tsv|json> File format (default: csv)
33
34
  --json-pretty Pretty-print JSON on export
35
+ --git Stable filenames: <key>.mcdata.<ext> (no timestamp)
34
36
 
35
37
  Import options:
36
- --api <async|sync> REST row API family (default: async)
37
- --mode <upsert|insert|update> (default: upsert; insert/update require --api sync)
38
+ --mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
38
39
  --clear-before-import SOAP ClearData before import (destructive; see below)
39
40
  --i-accept-clear-data-risk Non-interactive acknowledgement for --clear-before-import
40
41
 
41
42
  Multi-BU options:
42
43
  --from <cred>/<bu> Export: source BU (repeatable for multiple sources)
43
- Import: single source BU (use with --to)
44
+ Import (API mode): single source BU (use with --to and --de)
44
45
  --to <cred>/<bu> Import: target BU (repeatable for multiple targets)
46
+ Import (file mode): use with --file only (no --from needed)
45
47
 
46
48
  Notes:
47
- Exports are written under ./data/<credential>/<bu>/ with "+MCDATA+" in the filename.
49
+ Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
48
50
  Import with --de resolves the latest matching file in that folder (by mtime).
49
- Import with --file parses the DE key from the basename (+MCDATA+ prefix).
50
- Cross-BU import stores a timestamped download file in each target BU's data directory.
51
+ Import with --file parses the DE key from the basename (.mcdata. format).
52
+ Cross-BU import stores a download file in each target BU's data directory.
51
53
 
52
54
  Clear data warning:
53
55
  --clear-before-import deletes ALL existing rows in the target DE(s) before upload.
@@ -72,10 +74,10 @@ export async function main(argv) {
72
74
  format: { type: 'string' },
73
75
  de: { type: 'string', multiple: true },
74
76
  file: { type: 'string', multiple: true },
75
- api: { type: 'string' },
76
- mode: { type: 'string' },
77
77
  from: { type: 'string', multiple: true },
78
78
  to: { type: 'string', multiple: true },
79
+ git: { type: 'boolean', default: false },
80
+ mode: { type: 'string' },
79
81
  'clear-before-import': { type: 'boolean', default: false },
80
82
  'i-accept-clear-data-risk': { type: 'boolean', default: false },
81
83
  'json-pretty': { type: 'boolean', default: false },
@@ -109,6 +111,7 @@ export async function main(argv) {
109
111
  return 1;
110
112
  }
111
113
 
114
+ const useGit = values.git === true;
112
115
  const fromFlags = values.from ?? [];
113
116
  const toFlags = values.to ?? [];
114
117
  const hasFrom = fromFlags.length > 0;
@@ -140,7 +143,6 @@ export async function main(argv) {
140
143
  }
141
144
 
142
145
  if (hasFrom) {
143
- // Multi-BU export
144
146
  let sources;
145
147
  try {
146
148
  sources = fromFlags.map(parseCredBu);
@@ -157,46 +159,78 @@ export async function main(argv) {
157
159
  deKeys: des,
158
160
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
159
161
  jsonPretty: values['json-pretty'],
162
+ useGit,
160
163
  });
161
164
  return 0;
162
165
  }
163
166
 
164
- // Single-BU export (original behavior)
165
167
  const { credential, bu } = parseCredBu(credBuRaw);
166
168
  const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
167
169
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
168
170
  const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
169
171
  for (const deKey of des) {
170
- const out = await exportDataExtensionToFile(sdk, {
172
+ const { path: out, rowCount } = await exportDataExtensionToFile(sdk, {
171
173
  projectRoot,
172
174
  credentialName: credential,
173
175
  buName: bu,
174
176
  deKey,
175
177
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
176
178
  jsonPretty: values['json-pretty'],
179
+ useGit,
177
180
  });
178
- console.error(`Exported: ${out}`);
181
+ const rel = projectRelativePosix(projectRoot, out);
182
+ console.error(`Exported: ${rel} (${rowCount} rows)`);
179
183
  }
180
184
  return 0;
181
185
  }
182
186
 
183
187
  // ── import ──────────────────────────────────────────────────────────────
184
188
  if (sub === 'import') {
185
- const api = values.api ?? 'async';
186
189
  const mode = values.mode ?? 'upsert';
187
- if (!['async', 'sync'].includes(api)) {
188
- console.error(`Invalid --api: ${api}`);
189
- return 1;
190
- }
191
- if (!['upsert', 'insert', 'update'].includes(mode)) {
192
- console.error(`Invalid --mode: ${mode}`);
190
+ if (!['upsert', 'insert'].includes(mode)) {
191
+ console.error(`Invalid --mode: ${mode} (use upsert or insert)`);
193
192
  return 1;
194
193
  }
195
194
 
196
195
  const clear = values['clear-before-import'];
197
196
  const acceptRisk = values['i-accept-clear-data-risk'];
198
197
 
199
- // ── Cross-BU import: --from + --to ──────────────────────────────────
198
+ // ── File-to-multi-BU import: --to + --file (no --from) ─────────────
199
+ if (hasTo && !hasFrom && values.file?.length > 0) {
200
+ if (hasPositional) {
201
+ console.error('Cannot mix a positional <credential>/<bu> with --to/--file. Use one or the other.');
202
+ return 1;
203
+ }
204
+ if (values.de?.length > 0) {
205
+ console.error('Cannot mix --de with --file in multi-target import. Use --file only.');
206
+ return 1;
207
+ }
208
+ const filePaths = values.file;
209
+ let targets;
210
+ try {
211
+ targets = toFlags.map(parseCredBu);
212
+ } catch (e) {
213
+ console.error(e.message);
214
+ return 1;
215
+ }
216
+ const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
217
+ await crossBuImport({
218
+ projectRoot,
219
+ mcdevrc,
220
+ mcdevAuth,
221
+ filePaths,
222
+ targets,
223
+ format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
224
+ mode: /** @type {'upsert'|'insert'} */ (mode),
225
+ clearBeforeImport: clear,
226
+ acceptRiskFlag: acceptRisk,
227
+ isTTY: process.stdin.isTTY === true,
228
+ useGit,
229
+ });
230
+ return 0;
231
+ }
232
+
233
+ // ── Cross-BU import (API mode): --from + --to + --de ────────────────
200
234
  if (hasFrom || hasTo) {
201
235
  if (hasPositional) {
202
236
  console.error('Cannot mix a positional <credential>/<bu> with --from/--to. Use one or the other.');
@@ -215,7 +249,7 @@ export async function main(argv) {
215
249
  return 1;
216
250
  }
217
251
  if (values.file?.length > 0) {
218
- console.error('--file cannot be combined with --from/--to. Use --de instead.');
252
+ console.error('--file cannot be combined with --from/--to/--de. For file-based multi-target import use --to + --file (without --from).');
219
253
  return 1;
220
254
  }
221
255
  const deKeys = [].concat(values.de ?? []);
@@ -242,11 +276,11 @@ export async function main(argv) {
242
276
  targets,
243
277
  deKeys,
244
278
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
245
- api: /** @type {'async'|'sync'} */ (api),
246
- mode: /** @type {'upsert'|'insert'|'update'} */ (mode),
279
+ mode: /** @type {'upsert'|'insert'} */ (mode),
247
280
  clearBeforeImport: clear,
248
281
  acceptRiskFlag: acceptRisk,
249
282
  isTTY: process.stdin.isTTY === true,
283
+ useGit,
250
284
  });
251
285
  return 0;
252
286
  }
@@ -286,19 +320,19 @@ export async function main(argv) {
286
320
  for (const deKey of deKeys) {
287
321
  const candidates = await findImportCandidates(dataDir, deKey, fmt);
288
322
  if (candidates.length === 0) {
289
- console.error(`No ${fmt} file found for DE "${deKey}" under ${dataDir}`);
323
+ console.error(`No ${fmt} file found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`);
290
324
  return 1;
291
325
  }
292
326
  const filePath =
293
327
  candidates.length === 1 ? candidates[0] : await pickLatestByMtime(candidates);
294
- await importFromFile(sdk, {
328
+ const n = await importFromFile(sdk, {
295
329
  filePath,
296
330
  deKey,
297
331
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
298
- api: /** @type {'async'|'sync'} */ (api),
299
- mode: /** @type {'upsert'|'insert'|'update'} */ (mode),
332
+ mode: /** @type {'upsert'|'insert'} */ (mode),
300
333
  });
301
- console.error(`Imported ${filePath} -> DE ${deKey}`);
334
+ const rel = projectRelativePosix(projectRoot, filePath);
335
+ console.error(`Imported: ${rel} (${n} rows) -> DE ${deKey}`);
302
336
  }
303
337
  return 0;
304
338
  }
@@ -318,14 +352,14 @@ export async function main(argv) {
318
352
  for (const filePath of fileList) {
319
353
  const base = path.basename(filePath);
320
354
  const { customerKey } = parseExportBasename(base);
321
- await importFromFile(sdk, {
355
+ const n = await importFromFile(sdk, {
322
356
  filePath,
323
357
  deKey: customerKey,
324
358
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
325
- api: /** @type {'async'|'sync'} */ (api),
326
- mode: /** @type {'upsert'|'insert'|'update'} */ (mode),
359
+ mode: /** @type {'upsert'|'insert'} */ (mode),
327
360
  });
328
- console.error(`Imported ${filePath}`);
361
+ const rel = projectRelativePosix(projectRoot, filePath);
362
+ console.error(`Imported: ${rel} (${n} rows)`);
329
363
  }
330
364
  return 0;
331
365
  }
@@ -4,13 +4,13 @@ import readline from 'node:readline/promises';
4
4
  import { stdin as input, stdout as output } from 'node:process';
5
5
  import SDK from 'sfmc-sdk';
6
6
  import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
7
- import { fetchAllRowObjects, serializeRows } from './export-de.mjs';
8
- import { exportDataExtensionToFile } from './export-de.mjs';
7
+ import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
9
8
  import { importRowsForDe } from './import-de.mjs';
9
+ import { readRowsFromFile } from './read-rows.mjs';
10
10
  import { clearDataExtensionRows } from './clear-de.mjs';
11
11
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
12
- import { dataDirectoryForBu } from './paths.mjs';
13
- import { buildExportBasename, filesystemSafeTimestamp } from './filename.mjs';
12
+ import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
13
+ import { buildExportBasename, filesystemSafeTimestamp, parseExportBasename } from './filename.mjs';
14
14
 
15
15
  /**
16
16
  * @typedef {{ credential: string, bu: string }} CredBuTarget
@@ -51,27 +51,31 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
51
51
  * Imports Data Extension rows from a single source BU into one or more target
52
52
  * BUs.
53
53
  *
54
- * Before the import each target BU:
55
- * 1. Optionally exports its current DE data as a timestamped backup (TTY only).
56
- * 2. Optionally clears all existing rows (with danger warning covering every target).
57
- * 3. Receives the source rows written to a timestamped "download" file in its
58
- * own `./data/<credential>/<bu>/` directory (mirroring mcdev retrieve).
59
- * 4. Has the rows imported via the REST API.
54
+ * Two source modes are supported:
55
+ *
56
+ * **API mode** (default): rows are fetched live from the source BU via the
57
+ * SFMC REST API. Requires `sourceCred`, `sourceBu`, and `deKeys`.
58
+ *
59
+ * **File mode**: rows are read from local export files (e.g. previously
60
+ * created by `mcdata export`). Requires `filePaths`; `deKeys` and
61
+ * `sourceCred`/`sourceBu` must be omitted. The DE customer key is derived
62
+ * from each filename via `parseExportBasename`.
60
63
  *
61
64
  * @param {object} params
62
65
  * @param {string} params.projectRoot
63
66
  * @param {import('./config.mjs').Mcdevrc} params.mcdevrc
64
67
  * @param {Record<string, import('./config.mjs').AuthCredential>} params.mcdevAuth
65
- * @param {string} params.sourceCred
66
- * @param {string} params.sourceBu
68
+ * @param {string} [params.sourceCred] - API mode only
69
+ * @param {string} [params.sourceBu] - API mode only
70
+ * @param {string[]} [params.deKeys] - API mode only
71
+ * @param {string[]} [params.filePaths] - File mode only; mutually exclusive with sourceCred/sourceBu/deKeys
67
72
  * @param {CredBuTarget[]} params.targets
68
- * @param {string[]} params.deKeys
69
73
  * @param {'csv'|'tsv'|'json'} params.format
70
- * @param {'async'|'sync'} params.api
71
- * @param {'upsert'|'insert'|'update'} params.mode
74
+ * @param {'upsert'|'insert'} params.mode
72
75
  * @param {boolean} params.clearBeforeImport
73
76
  * @param {boolean} params.acceptRiskFlag
74
77
  * @param {boolean} params.isTTY
78
+ * @param {boolean} [params.useGit] - stable snapshot basename (no timestamp)
75
79
  * @param {NodeJS.ReadableStream} [params.stdin] Override for testing
76
80
  * @param {NodeJS.WritableStream} [params.stdout] Override for testing
77
81
  * @returns {Promise<void>}
@@ -81,28 +85,48 @@ export async function crossBuImport(params) {
81
85
  projectRoot,
82
86
  mcdevrc,
83
87
  mcdevAuth,
84
- sourceCred,
85
- sourceBu,
86
88
  targets,
87
- deKeys,
88
89
  format,
89
- api,
90
90
  mode,
91
91
  clearBeforeImport,
92
92
  acceptRiskFlag,
93
93
  isTTY,
94
+ useGit = false,
94
95
  } = params;
95
96
  const stdin = params.stdin;
96
97
  const stdout = params.stdout;
97
98
 
98
- // Validate all BU configurations upfront before making any API calls
99
- const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(mcdevrc, mcdevAuth, sourceCred, sourceBu);
99
+ // Determine source mode
100
+ const filePaths = params.filePaths ?? null;
101
+ const isFileBased = filePaths !== null && filePaths.length > 0;
102
+
103
+ // Derive DE keys: from explicit list (API mode) or from filenames (file mode)
104
+ const deKeys = isFileBased
105
+ ? filePaths.map((fp) => parseExportBasename(path.basename(fp)).customerKey)
106
+ : (params.deKeys ?? []);
107
+
108
+ // Build a lookup map from deKey → filePath for file mode
109
+ /** @type {Map<string, string>} */
110
+ const fileByDeKey = new Map();
111
+ if (isFileBased) {
112
+ for (const fp of filePaths) {
113
+ fileByDeKey.set(parseExportBasename(path.basename(fp)).customerKey, fp);
114
+ }
115
+ }
116
+
117
+ // Validate all target BU configurations upfront
100
118
  for (const { credential, bu } of targets) {
101
119
  resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
102
120
  }
103
121
 
104
- // Connect to source BU
105
- const srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), { requestAttempts: 3 });
122
+ // Connect to source BU (API mode only)
123
+ let srcSdk = null;
124
+ if (!isFileBased) {
125
+ const { mid: srcMid, authCred: srcAuth } = resolveCredentialAndMid(
126
+ mcdevrc, mcdevAuth, params.sourceCred, params.sourceBu
127
+ );
128
+ srcSdk = new SDK(buildSdkAuthObject(srcAuth, srcMid), { requestAttempts: 3 });
129
+ }
106
130
 
107
131
  // Optional pre-import backup of target BU data
108
132
  if (isTTY) {
@@ -112,14 +136,16 @@ export async function crossBuImport(params) {
112
136
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
113
137
  const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
114
138
  for (const deKey of deKeys) {
115
- const outPath = await exportDataExtensionToFile(tgtSdk, {
139
+ const { path: outPath, rowCount } = await exportDataExtensionToFile(tgtSdk, {
116
140
  projectRoot,
117
141
  credentialName: credential,
118
142
  buName: bu,
119
143
  deKey,
120
144
  format,
145
+ useGit: false,
121
146
  });
122
- console.error(`Backup export: ${outPath}`);
147
+ const rel = projectRelativePosix(projectRoot, outPath);
148
+ console.error(`Backup export: ${rel} (${rowCount} rows)`);
123
149
  }
124
150
  }
125
151
  }
@@ -130,9 +156,11 @@ export async function crossBuImport(params) {
130
156
  await confirmClearBeforeImport({ deKeys, targets, acceptRiskFlag, isTTY, stdin, stdout });
131
157
  }
132
158
 
133
- // Fetch rows once per DE from the source, then fan out to every target
159
+ // Load rows once per DE then fan out to every target
134
160
  for (const deKey of deKeys) {
135
- const rows = await fetchAllRowObjects(srcSdk, deKey);
161
+ const rows = isFileBased
162
+ ? await readRowsFromFile(fileByDeKey.get(deKey), format)
163
+ : await fetchAllRowObjects(srcSdk, deKey);
136
164
 
137
165
  // Clear targets before import (rows already confirmed above)
138
166
  if (clearBeforeImport) {
@@ -147,19 +175,18 @@ export async function crossBuImport(params) {
147
175
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
148
176
  const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
149
177
 
150
- // Write a timestamped "download" file in the target BU's data directory.
151
- // This mirrors what mcdev retrieve / mcdata export produces, giving a
152
- // traceable snapshot of exactly what was imported.
178
+ // Write a snapshot file in the target BU's data directory.
153
179
  const dir = dataDirectoryForBu(projectRoot, credential, bu);
154
180
  await fs.mkdir(dir, { recursive: true });
155
181
  const ts = filesystemSafeTimestamp();
156
- const basename = buildExportBasename(deKey, ts, format);
157
- const filePath = path.join(dir, basename);
158
- await fs.writeFile(filePath, serializeRows(rows, format, false), 'utf8');
159
- console.error(`Download stored: ${filePath}`);
182
+ const basename = buildExportBasename(deKey, ts, format, useGit);
183
+ const snapshotPath = path.join(dir, basename);
184
+ await fs.writeFile(snapshotPath, serializeRows(rows, format, false), 'utf8');
185
+ const snapRel = projectRelativePosix(projectRoot, snapshotPath);
186
+ console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
160
187
 
161
- await importRowsForDe(tgtSdk, { deKey, rows, api, mode });
162
- console.error(`Imported -> ${credential}/${bu} DE ${deKey}`);
188
+ const imported = await importRowsForDe(tgtSdk, { deKey, rows, mode });
189
+ console.error(`Imported: ${credential}/${bu} DE ${deKey} (${imported} rows)`);
163
190
  }
164
191
  }
165
192
  }
package/lib/export-de.mjs CHANGED
@@ -6,37 +6,17 @@ import { buildExportBasename, filesystemSafeTimestamp } from './filename.mjs';
6
6
  import { dataDirectoryForBu } from './paths.mjs';
7
7
 
8
8
  /**
9
- * @param {{ rest: { get: (path: string) => Promise<any> } }} sdk
9
+ * @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise<any> } }} sdk
10
10
  * @param {string} deKey
11
11
  * @returns {Promise<object[]>}
12
12
  */
13
13
  export async function fetchAllRowObjects(sdk, deKey) {
14
- const pageSize = 2500;
15
- let page = 1;
14
+ const basePath = rowsetGetPath(deKey);
15
+ const data = await sdk.rest.getBulk(basePath, 2500);
16
+ const items = data.items ?? [];
16
17
  const rows = [];
17
- let hasMore = true;
18
- while (hasMore) {
19
- const qs = new URLSearchParams({
20
- page: String(page),
21
- pageSize: String(pageSize),
22
- });
23
- const urlPath = `${rowsetGetPath(deKey)}?${qs.toString()}`;
24
- const data = await sdk.rest.get(urlPath);
25
- const items = data.items ?? [];
26
- for (const item of items) {
27
- rows.push({ ...item.keys, ...item.values });
28
- }
29
- if (items.length === 0) {
30
- hasMore = false;
31
- } else if (data.hasMoreRows === false) {
32
- hasMore = false;
33
- } else if (data.hasMoreRows === true) {
34
- hasMore = true;
35
- page++;
36
- } else {
37
- hasMore = items.length === pageSize;
38
- page++;
39
- }
18
+ for (const item of items) {
19
+ rows.push({ ...item.keys, ...item.values });
40
20
  }
41
21
  return rows;
42
22
  }
@@ -62,7 +42,7 @@ export function serializeRows(rows, format, jsonPretty) {
62
42
  }
63
43
 
64
44
  /**
65
- * @param {{ rest: { get: (path: string) => Promise<any> } }} sdk
45
+ * @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise<any> } }} sdk
66
46
  * @param {object} params
67
47
  * @param {string} params.projectRoot
68
48
  * @param {string} params.credentialName
@@ -70,17 +50,18 @@ export function serializeRows(rows, format, jsonPretty) {
70
50
  * @param {string} params.deKey
71
51
  * @param {'csv'|'tsv'|'json'} params.format
72
52
  * @param {boolean} [params.jsonPretty]
73
- * @returns {Promise<string>} written file path
53
+ * @param {boolean} [params.useGit]
54
+ * @returns {Promise<{ path: string, rowCount: number }>}
74
55
  */
75
56
  export async function exportDataExtensionToFile(sdk, params) {
76
- const { projectRoot, credentialName, buName, deKey, format, jsonPretty = false } = params;
57
+ const { projectRoot, credentialName, buName, deKey, format, jsonPretty = false, useGit = false } = params;
77
58
  const rows = await fetchAllRowObjects(sdk, deKey);
78
59
  const dir = dataDirectoryForBu(projectRoot, credentialName, buName);
79
60
  await fs.mkdir(dir, { recursive: true });
80
61
  const ts = filesystemSafeTimestamp();
81
- const basename = buildExportBasename(deKey, ts, format);
62
+ const basename = buildExportBasename(deKey, ts, format, useGit);
82
63
  const outPath = path.join(dir, basename);
83
64
  const body = serializeRows(rows, format, jsonPretty);
84
65
  await fs.writeFile(outPath, body, 'utf8');
85
- return outPath;
66
+ return { path: outPath, rowCount: rows.length };
86
67
  }
@@ -1,9 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { filterIllegalFilenames, MCDATA_SENTINEL } from './filename.mjs';
3
+ import { parseExportBasename } from './filename.mjs';
4
4
 
5
5
  /**
6
- * Find export files under data dir matching encoded DE key prefix and extension.
6
+ * Find export files under data dir matching the DE customer key and extension.
7
7
  *
8
8
  * @param {string} dataDir
9
9
  * @param {string} customerKey
@@ -11,7 +11,6 @@ import { filterIllegalFilenames, MCDATA_SENTINEL } from './filename.mjs';
11
11
  * @returns {Promise<string[]>} full paths
12
12
  */
13
13
  export async function findImportCandidates(dataDir, customerKey, format) {
14
- const prefix = filterIllegalFilenames(customerKey) + MCDATA_SENTINEL;
15
14
  let entries;
16
15
  try {
17
16
  entries = await fs.readdir(dataDir, { withFileTypes: true });
@@ -28,8 +27,13 @@ export async function findImportCandidates(dataDir, customerKey, format) {
28
27
  if (!name.endsWith(`.${ext}`)) {
29
28
  continue;
30
29
  }
31
- if (name.startsWith(prefix)) {
32
- matches.push(path.join(dataDir, name));
30
+ try {
31
+ const { customerKey: parsedKey } = parseExportBasename(name);
32
+ if (parsedKey === customerKey) {
33
+ matches.push(path.join(dataDir, name));
34
+ }
35
+ } catch {
36
+ continue;
33
37
  }
34
38
  }
35
39
  return matches;
package/lib/filename.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  * @see https://github.com/Accenture/sfmc-devtools (lib/util/file.js)
5
5
  */
6
6
 
7
+ /** Literal segment between encoded DE key and timestamp (or extension in --git mode). */
8
+ export const MCDATA_SEGMENT = '.mcdata.';
9
+
7
10
  /**
8
11
  * @param {string} filename
9
12
  * @returns {string}
@@ -35,17 +38,19 @@ export function reverseFilterIllegalFilenames(filename) {
35
38
  return decodeURIComponent(filename).split('_STAR_').join('*');
36
39
  }
37
40
 
38
- /** Sentinel between encoded DE key and timestamp in export basenames; cannot appear in the key segment after encoding. */
39
- export const MCDATA_SENTINEL = '+MCDATA+';
40
-
41
41
  /**
42
42
  * @param {string} customerKey
43
- * @param {string} safeTs - filesystem-safe UTC timestamp
43
+ * @param {string} safeTs - filesystem-safe UTC timestamp (ignored when useGit is true)
44
44
  * @param {'csv'|'tsv'|'json'} ext
45
+ * @param {boolean} [useGit] - stable `key.mcdata.ext` without timestamp
45
46
  * @returns {string} basename without directory
46
47
  */
47
- export function buildExportBasename(customerKey, safeTs, ext) {
48
- return `${filterIllegalFilenames(customerKey)}${MCDATA_SENTINEL}${safeTs}.${ext}`;
48
+ export function buildExportBasename(customerKey, safeTs, ext, useGit = false) {
49
+ const enc = filterIllegalFilenames(customerKey);
50
+ if (useGit) {
51
+ return `${enc}.mcdata.${ext}`;
52
+ }
53
+ return `${enc}${MCDATA_SEGMENT}${safeTs}.${ext}`;
49
54
  }
50
55
 
51
56
  /**
@@ -57,24 +62,33 @@ export function filesystemSafeTimestamp(d = new Date()) {
57
62
  }
58
63
 
59
64
  /**
60
- * @param {string} basename - e.g. `encodedKey+MCDATA+2026-04-06T15-00-00.000Z.csv`
65
+ * @param {string} basename - e.g. `encodedKey.mcdata.2026-04-06T15-00-00.000Z.csv` or `encodedKey.mcdata.csv`
61
66
  * @returns {{ customerKey: string, timestampPart: string, ext: string }}
62
67
  */
63
68
  export function parseExportBasename(basename) {
64
69
  const lastDot = basename.lastIndexOf('.');
65
70
  const stem = lastDot === -1 ? basename : basename.slice(0, lastDot);
66
- const ext = lastDot === -1 ? '' : basename.slice(lastDot + 1);
67
- const idx = stem.indexOf(MCDATA_SENTINEL);
68
- if (idx === -1) {
69
- throw new Error(
70
- `Filename must contain "${MCDATA_SENTINEL}" between encoded key and timestamp: ${basename}`
71
- );
71
+ const ext = lastDot === -1 ? '' : basename.slice(lastDot + 1).toLowerCase();
72
+
73
+ const idx = stem.indexOf(MCDATA_SEGMENT);
74
+ if (idx !== -1) {
75
+ const encodedKey = stem.slice(0, idx);
76
+ const timestampPart = stem.slice(idx + MCDATA_SEGMENT.length);
77
+ return {
78
+ customerKey: reverseFilterIllegalFilenames(encodedKey),
79
+ timestampPart,
80
+ ext,
81
+ };
72
82
  }
73
- const encodedKey = stem.slice(0, idx);
74
- const timestampPart = stem.slice(idx + MCDATA_SENTINEL.length);
75
- return {
76
- customerKey: reverseFilterIllegalFilenames(encodedKey),
77
- timestampPart,
78
- ext: ext.toLowerCase(),
79
- };
83
+
84
+ if (stem.endsWith('.mcdata')) {
85
+ const encodedKey = stem.slice(0, -'.mcdata'.length);
86
+ return {
87
+ customerKey: reverseFilterIllegalFilenames(encodedKey),
88
+ timestampPart: '',
89
+ ext,
90
+ };
91
+ }
92
+
93
+ throw new Error(`Filename must contain ".mcdata." or end with ".mcdata" before the extension: ${basename}`);
80
94
  }
package/lib/import-de.mjs CHANGED
@@ -8,23 +8,23 @@ import { readRowsFromFile } from './read-rows.mjs';
8
8
  * @param {object} params
9
9
  * @param {string} params.deKey
10
10
  * @param {object[]} params.rows
11
- * @param {'async'|'sync'} params.api
12
- * @param {'upsert'|'insert'|'update'} params.mode
13
- * @returns {Promise<void>}
11
+ * @param {'upsert'|'insert'} params.mode
12
+ * @returns {Promise<number>} number of rows imported
14
13
  */
15
14
  export async function importRowsForDe(sdk, params) {
16
- const { deKey, rows, api, mode } = params;
17
- const route = resolveImportRoute(api, mode);
15
+ const { deKey, rows, mode } = params;
16
+ const route = resolveImportRoute(mode);
18
17
  const chunks = chunkItemsForPayload(rows);
19
18
  for (const chunk of chunks) {
20
- const path = route.path(deKey);
19
+ const p = route.path(deKey);
21
20
  const body = { items: chunk };
22
21
  await withRetry429(() =>
23
22
  route.method === 'PUT'
24
- ? sdk.rest.put(path, body)
25
- : sdk.rest.post(path, body)
23
+ ? sdk.rest.put(p, body)
24
+ : sdk.rest.post(p, body)
26
25
  );
27
26
  }
27
+ return rows.length;
28
28
  }
29
29
 
30
30
  /**
@@ -33,16 +33,14 @@ export async function importRowsForDe(sdk, params) {
33
33
  * @param {string} params.filePath
34
34
  * @param {string} params.deKey - target DE customer key for API
35
35
  * @param {'csv'|'tsv'|'json'} params.format
36
- * @param {'async'|'sync'} params.api
37
- * @param {'upsert'|'insert'|'update'} params.mode
38
- * @returns {Promise<void>}
36
+ * @param {'upsert'|'insert'} params.mode
37
+ * @returns {Promise<number>} number of rows imported
39
38
  */
40
39
  export async function importFromFile(sdk, params) {
41
40
  const rows = await readRowsFromFile(params.filePath, params.format);
42
- await importRowsForDe(sdk, {
41
+ return importRowsForDe(sdk, {
43
42
  deKey: params.deKey,
44
43
  rows,
45
- api: params.api,
46
44
  mode: params.mode,
47
45
  });
48
46
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * REST paths for Data Extension row writes (relative to REST base URL).
2
+ * REST paths for Data Extension row operations (relative to REST base URL).
3
3
  * Confirm against current Salesforce reference hubs when adjusting behavior.
4
4
  */
5
5
 
@@ -12,56 +12,24 @@ export function rowsetGetPath(deKey) {
12
12
  }
13
13
 
14
14
  /**
15
- * Async bulk upsert (default for `--api async`).
15
+ * Async bulk row writes: POST insert, PUT upsert (same URL).
16
16
  * @param {string} deKey
17
17
  * @returns {string}
18
18
  */
19
- export function asyncUpsertPath(deKey) {
19
+ export function asyncDataExtensionRowsPath(deKey) {
20
20
  return `/data/v1/async/dataextensions/key:${encodeURIComponent(deKey)}/rows`;
21
21
  }
22
22
 
23
23
  /**
24
- * Synchronous upsert row set.
25
- * @param {string} deKey
26
- * @returns {string}
27
- */
28
- export function syncUpsertPath(deKey) {
29
- return `/data/v1/customobjectdata/key/${encodeURIComponent(deKey)}/rows`;
30
- }
31
-
32
- /**
33
- * Synchronous insert row set (POST).
34
- * @param {string} deKey
35
- * @returns {string}
36
- */
37
- export function syncInsertPath(deKey) {
38
- return `/data/v1/customobjectdata/key/${encodeURIComponent(deKey)}/rows`;
39
- }
40
-
41
- /**
42
- * @param {'async'|'sync'} api
43
- * @param {'upsert'|'insert'|'update'} mode
24
+ * @param {'upsert'|'insert'} mode
44
25
  * @returns {{ method: 'PUT'|'POST', path: (de: string) => string }}
45
26
  */
46
- export function resolveImportRoute(api, mode) {
47
- if (api === 'async') {
48
- if (mode !== 'upsert') {
49
- throw new Error(
50
- `Import mode "${mode}" is not supported with --api async (use --api sync or --mode upsert).`
51
- );
52
- }
53
- return { method: 'PUT', path: asyncUpsertPath };
27
+ export function resolveImportRoute(mode) {
28
+ if (mode === 'upsert') {
29
+ return { method: 'PUT', path: asyncDataExtensionRowsPath };
54
30
  }
55
- if (api === 'sync') {
56
- if (mode === 'upsert') {
57
- return { method: 'PUT', path: syncUpsertPath };
58
- }
59
- if (mode === 'insert') {
60
- return { method: 'POST', path: syncInsertPath };
61
- }
62
- if (mode === 'update') {
63
- return { method: 'PUT', path: syncUpsertPath };
64
- }
31
+ if (mode === 'insert') {
32
+ return { method: 'POST', path: asyncDataExtensionRowsPath };
65
33
  }
66
- throw new Error(`Unsupported --api / --mode combination: ${api} + ${mode}`);
34
+ throw new Error(`Unsupported import mode "${mode}" (use upsert or insert).`);
67
35
  }
package/lib/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  export { main } from './cli.mjs';
2
2
  export { filterIllegalFilenames, reverseFilterIllegalFilenames, parseExportBasename } from './filename.mjs';
3
3
  export { chunkItemsForPayload, DEFAULT_MAX_BODY_BYTES, MAX_OBJECTS_PER_BATCH } from './batch.mjs';
4
- export { resolveImportRoute, rowsetGetPath, asyncUpsertPath } from './import-routes.mjs';
4
+ export { resolveImportRoute, rowsetGetPath, asyncDataExtensionRowsPath } from './import-routes.mjs';
5
5
  export { loadMcdevProject, parseCredBu, resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
6
6
  export { multiBuExport } from './multi-bu-export.mjs';
7
7
  export { crossBuImport } from './cross-bu-import.mjs';
8
+ export { projectRelativePosix } from './paths.mjs';
@@ -1,6 +1,7 @@
1
1
  import SDK from 'sfmc-sdk';
2
2
  import { resolveCredentialAndMid, buildSdkAuthObject } from './config.mjs';
3
3
  import { exportDataExtensionToFile } from './export-de.mjs';
4
+ import { projectRelativePosix } from './paths.mjs';
4
5
 
5
6
  /**
6
7
  * @typedef {{ credential: string, bu: string }} CredBuSource
@@ -19,24 +20,27 @@ import { exportDataExtensionToFile } from './export-de.mjs';
19
20
  * @param {string[]} params.deKeys
20
21
  * @param {'csv'|'tsv'|'json'} params.format
21
22
  * @param {boolean} [params.jsonPretty]
23
+ * @param {boolean} [params.useGit]
22
24
  * @returns {Promise<string[]>} Paths of all written files
23
25
  */
24
- export async function multiBuExport({ projectRoot, mcdevrc, mcdevAuth, sources, deKeys, format, jsonPretty = false }) {
26
+ export async function multiBuExport({ projectRoot, mcdevrc, mcdevAuth, sources, deKeys, format, jsonPretty = false, useGit = false }) {
25
27
  /** @type {string[]} */
26
28
  const exported = [];
27
29
  for (const { credential, bu } of sources) {
28
30
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
29
31
  const sdk = new SDK(buildSdkAuthObject(authCred, mid), { requestAttempts: 3 });
30
32
  for (const deKey of deKeys) {
31
- const outPath = await exportDataExtensionToFile(sdk, {
33
+ const { path: outPath, rowCount } = await exportDataExtensionToFile(sdk, {
32
34
  projectRoot,
33
35
  credentialName: credential,
34
36
  buName: bu,
35
37
  deKey,
36
38
  format,
37
39
  jsonPretty,
40
+ useGit,
38
41
  });
39
- console.error(`Exported: ${outPath}`);
42
+ const rel = projectRelativePosix(projectRoot, outPath);
43
+ console.error(`Exported: ${rel} (${rowCount} rows)`);
40
44
  exported.push(outPath);
41
45
  }
42
46
  }
package/lib/paths.mjs CHANGED
@@ -9,3 +9,19 @@ import path from 'node:path';
9
9
  export function dataDirectoryForBu(projectRoot, credentialName, buName) {
10
10
  return path.join(projectRoot, 'data', credentialName, buName);
11
11
  }
12
+
13
+ /**
14
+ * Path relative to mcdev project root for logs (POSIX-style, `./`-prefixed when needed).
15
+ *
16
+ * @param {string} projectRoot
17
+ * @param {string} absolutePath
18
+ * @returns {string}
19
+ */
20
+ export function projectRelativePosix(projectRoot, absolutePath) {
21
+ const rel = path.relative(path.resolve(projectRoot), path.resolve(absolutePath));
22
+ const norm = rel.split(path.sep).join('/');
23
+ if (norm === '' || norm === '.') {
24
+ return './';
25
+ }
26
+ return norm.startsWith('.') ? norm : `./${norm}`;
27
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "1.1.0",
4
- "description": "CLI (mcdata) to export and import Marketing Cloud Data Extension rows using mcdev project config and sfmc-sdk",
3
+ "version": "2.0.0",
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",
7
7
  "repository": {