sfmc-dataloader 2.4.0 → 2.4.2

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.
@@ -4,7 +4,12 @@ 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, buildSdkOptions } from './config.mjs';
7
- import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
7
+ import {
8
+ fetchAllRowObjects,
9
+ fetchDataExtensionFieldNames,
10
+ serializeRows,
11
+ exportDataExtensionToFile,
12
+ } from './export-de.mjs';
8
13
  import { formatFromExtension } from './file-resolve.mjs';
9
14
  import { importRowsForDe } from './import-de.mjs';
10
15
  import { pollAsyncImportCompletion } from './async-status.mjs';
@@ -184,10 +189,28 @@ export async function crossBuImport(params) {
184
189
  );
185
190
  }
186
191
  rows = await readRowsFromFile(filePath, detectedFormat);
192
+ if (rows.length === 0) {
193
+ throw new Error(
194
+ `Import file contains no data rows: "${filePath}". ` +
195
+ `The file may be empty, contain only a BOM, or contain only a header row. ` +
196
+ `Export the DE first to obtain a template with column names, then add rows.`,
197
+ );
198
+ }
187
199
  } else {
188
200
  rows = await fetchAllRowObjects(srcSdk, deKey);
189
201
  }
190
202
 
203
+ let snapshotColumns = [];
204
+ if (rows.length === 0 && format !== 'json') {
205
+ try {
206
+ snapshotColumns = await fetchDataExtensionFieldNames(srcSdk.soap, deKey);
207
+ } catch (ex) {
208
+ console.error(
209
+ `Warning: could not retrieve field names for empty DE "${deKey}" (snapshot): ${ex.message}`,
210
+ );
211
+ }
212
+ }
213
+
191
214
  for (const { credential, bu } of targets) {
192
215
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
193
216
  const tgtSdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
@@ -215,7 +238,11 @@ export async function crossBuImport(params) {
215
238
  const ts = filesystemSafeTimestamp();
216
239
  const basename = buildExportBasename(deKey, ts, format, false);
217
240
  const snapshotPath = path.join(dir, basename);
218
- await fs.writeFile(snapshotPath, serializeRows(rows, format, false), 'utf8');
241
+ await fs.writeFile(
242
+ snapshotPath,
243
+ serializeRows(rows, format, false, snapshotColumns),
244
+ 'utf8',
245
+ );
219
246
  const snapRel = projectRelativePosix(projectRoot, snapshotPath);
220
247
  console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
221
248
 
package/lib/export-de.mjs CHANGED
@@ -30,28 +30,57 @@ export async function fetchAllRowObjects(sdk, deKey) {
30
30
  return rows;
31
31
  }
32
32
 
33
+ /**
34
+ * Field names for a Data Extension, in ordinal order (SOAP).
35
+ *
36
+ * @param {{ retrieve: (type: string, props: string[], opts: object) => Promise.<any> }} soap
37
+ * @param {string} deKey - DE customer key
38
+ * @returns {Promise.<string[]>}
39
+ */
40
+ export async function fetchDataExtensionFieldNames(soap, deKey) {
41
+ const result = await soap.retrieve('DataExtensionField', ['Name', 'Ordinal'], {
42
+ filter: {
43
+ leftOperand: 'DataExtension.CustomerKey',
44
+ operator: 'equals',
45
+ rightOperand: deKey,
46
+ },
47
+ });
48
+ const fields = result?.Results;
49
+ if (!Array.isArray(fields) || fields.length === 0) {
50
+ return [];
51
+ }
52
+ return fields
53
+ .toSorted((a, b) => Number(a.Ordinal ?? 0) - Number(b.Ordinal ?? 0))
54
+ .map((f) => f.Name);
55
+ }
56
+
33
57
  /**
34
58
  * @param {object[]} rows
35
59
  * @param {'csv'|'tsv'|'json'} format
36
60
  * @param {boolean} jsonPretty
61
+ * @param {string[]} [columns] - when `rows` is empty and format is csv/tsv, emit this header row
37
62
  * @returns {string}
38
63
  */
39
- export function serializeRows(rows, format, jsonPretty) {
64
+ export function serializeRows(rows, format, jsonPretty, columns = []) {
40
65
  if (format === 'json') {
41
66
  const space = jsonPretty ? 2 : undefined;
42
67
  return JSON.stringify(rows, null, space) + '\n';
43
68
  }
44
69
  const delimiter = format === 'tsv' ? '\t' : ',';
45
- return stringify(rows, {
70
+ const options = {
46
71
  header: true,
47
72
  quoted: format === 'csv',
48
73
  bom: true,
49
74
  delimiter,
50
- });
75
+ };
76
+ if (rows.length === 0 && columns.length > 0) {
77
+ options.columns = columns;
78
+ }
79
+ return stringify(rows, options);
51
80
  }
52
81
 
53
82
  /**
54
- * @param {{rest: {getBulk: (path: string, pageSize?: number) => Promise.<any>}}} sdk
83
+ * @param {{ rest: { getBulk: (path: string, pageSize?: number) => Promise.<any> }, soap: { retrieve: Function } }} sdk
55
84
  * @param {object} params
56
85
  * @param {string} params.projectRoot
57
86
  * @param {string} params.credentialName
@@ -73,12 +102,22 @@ export async function exportDataExtensionToFile(sdk, params) {
73
102
  useGit = false,
74
103
  } = params;
75
104
  const rows = await fetchAllRowObjects(sdk, deKey);
105
+ let columns = [];
106
+ if (rows.length === 0 && format !== 'json') {
107
+ try {
108
+ columns = await fetchDataExtensionFieldNames(sdk.soap, deKey);
109
+ } catch (ex) {
110
+ console.error(
111
+ `Warning: could not retrieve field names for empty DE "${deKey}": ${ex.message}`,
112
+ );
113
+ }
114
+ }
76
115
  const dir = dataDirectoryForBu(projectRoot, credentialName, buName);
77
116
  await fs.mkdir(dir, { recursive: true });
78
117
  const ts = filesystemSafeTimestamp();
79
118
  const basename = buildExportBasename(deKey, ts, format, useGit);
80
119
  const outPath = path.join(dir, basename);
81
- const body = serializeRows(rows, format, jsonPretty);
120
+ const body = serializeRows(rows, format, jsonPretty, columns);
82
121
  await fs.writeFile(outPath, body, 'utf8');
83
122
  return { path: outPath, rowCount: rows.length };
84
123
  }
package/lib/import-de.mjs CHANGED
@@ -45,6 +45,13 @@ export async function importFromFile(sdk, params) {
45
45
  );
46
46
  }
47
47
  const rows = await readRowsFromFile(params.filePath, format);
48
+ if (rows.length === 0) {
49
+ throw new Error(
50
+ `Import file contains no data rows: "${params.filePath}". ` +
51
+ `The file may be empty, contain only a BOM, or contain only a header row. ` +
52
+ `Export the DE first to obtain a template with column names, then add rows.`,
53
+ );
54
+ }
48
55
  return importRowsForDe(sdk, {
49
56
  deKey: params.deKey,
50
57
  rows,
package/lib/read-rows.mjs CHANGED
@@ -29,7 +29,7 @@ export async function readRowsFromFile(filePath, format) {
29
29
  mapHeaders: ({ header }) => {
30
30
  let h = header;
31
31
  // Strip BOM if present (backup in case bom:true misses it)
32
- if (h.codePointAt(0) === 0xFEFF) {
32
+ if (h.startsWith('\uFEFF')) {
33
33
  h = h.slice(1);
34
34
  }
35
35
  // Strip surrounding quotes if present (non-standard quoted TSV)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
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",