@xeplr/actions 1.1.17 → 1.1.19

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.
@@ -0,0 +1,278 @@
1
+ // .xlsx writer — our own, on fflate's streaming zip.
2
+ //
3
+ // Why not exceljs: it pulls a uuid with a published advisory, has not had a
4
+ // release since December 2024, and the override that silences it has to be
5
+ // repeated in every app that installs this package. Writing SpreadsheetML is
6
+ // the other half of what lib/formats/_xlsx-stream.js already reads, and a
7
+ // workbook of one styled sheet is a small, well-specified thing.
8
+ //
9
+ // WHAT IT WRITES: one sheet; an optional title and subtitle above the header;
10
+ // the header bold on a tint, frozen, with an autofilter; numbers and dates as
11
+ // REAL cells carrying their number format, so the reader can still sum and
12
+ // filter; subtotal rows bold, total rows bold on a tint; column widths from
13
+ // the header and the first rows.
14
+ //
15
+ // Streaming: rows go straight into the zip, which goes straight to disk,
16
+ // waiting on the file stream when it is full — a million rows costs the
17
+ // same memory as a hundred. Only the first SAMPLE_ROWS are held, to size the
18
+ // columns, because <cols> must come before the rows in the sheet.
19
+
20
+ var fs = require('fs');
21
+
22
+ var MAX_ROWS = 1048576; // Excel's own limit, header and title rows included
23
+ var SAMPLE_ROWS = 200;
24
+ var CHUNK = 1 << 20; // push to the zip in ~1 MB pieces
25
+
26
+ // XML 1.0 cannot carry most control characters even escaped; Excel refuses
27
+ // the file ("We found a problem with some content"). Dropped.
28
+ var INVALID_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g;
29
+ function esc(value) {
30
+ return String(value).replace(INVALID_XML, '')
31
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
32
+ }
33
+
34
+ /** 0 → A, 25 → Z, 26 → AA. */
35
+ function colName(i) {
36
+ var s = '';
37
+ for (var n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
38
+ return s;
39
+ }
40
+
41
+ /** A Date, or an ISO date string, as an Excel serial — or null. */
42
+ function dateSerial(value) {
43
+ var d = value instanceof Date ? value : (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? new Date(value) : null);
44
+ if (!d || isNaN(d.getTime())) return null;
45
+ return d.getTime() / 86400000 + 25569;
46
+ }
47
+
48
+ // Fonts: 0 normal, 1 bold, 2 title. Fills: 0 none, 1 gray125 (both required
49
+ // by Excel at those positions), 2 header tint, 3 total tint.
50
+ var FONT = { normal: 0, bold: 1, title: 2 };
51
+ var FILL = { none: 0, header: 2, total: 3 };
52
+
53
+ function stylesXml(numFmts, xfs) {
54
+ var fmts = numFmts.map(function(code, i) { return '<numFmt numFmtId="' + (164 + i) + '" formatCode="' + esc(code) + '"/>'; });
55
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
56
+ '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
57
+ (fmts.length ? '<numFmts count="' + fmts.length + '">' + fmts.join('') + '</numFmts>' : '') +
58
+ '<fonts count="3"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font>' +
59
+ '<font><b/><sz val="14"/><name val="Calibri"/></font></fonts>' +
60
+ '<fills count="4"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>' +
61
+ '<fill><patternFill patternType="solid"><fgColor rgb="FFF1F3F7"/><bgColor indexed="64"/></patternFill></fill>' +
62
+ '<fill><patternFill patternType="solid"><fgColor rgb="FFE8EBF2"/><bgColor indexed="64"/></patternFill></fill></fills>' +
63
+ '<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border>' +
64
+ '<border><left/><right/><top style="thin"><color rgb="FF9AA1AE"/></top><bottom/><diagonal/></border></borders>' +
65
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
66
+ '<cellXfs count="' + xfs.length + '">' + xfs.map(function(x) {
67
+ return '<xf numFmtId="' + x.numFmtId + '" fontId="' + x.fontId + '" fillId="' + x.fillId + '" borderId="' + x.borderId + '" xfId="0"' +
68
+ (x.numFmtId ? ' applyNumberFormat="1"' : '') + (x.fontId ? ' applyFont="1"' : '') + (x.fillId ? ' applyFill="1"' : '') +
69
+ (x.borderId ? ' applyBorder="1"' : '') + (x.align ? ' applyAlignment="1"><alignment horizontal="' + x.align + '"/></xf>' : '/>');
70
+ }).join('') + '</cellXfs>' +
71
+ '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
72
+ }
73
+
74
+ var STATIC = {
75
+ '[Content_Types].xml': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
76
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
77
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
78
+ '<Default Extension="xml" ContentType="application/xml"/>' +
79
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
80
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
81
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
82
+ '</Types>',
83
+ '_rels/.rels': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
84
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
85
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
86
+ '</Relationships>',
87
+ 'xl/_rels/workbook.xml.rels': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
88
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
89
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
90
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
91
+ '</Relationships>'
92
+ };
93
+
94
+ function workbookXml(sheetName, filterRef) {
95
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
96
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
97
+ '<sheets><sheet name="' + esc(sheetName) + '" sheetId="1" r:id="rId1"/></sheets>' +
98
+ (filterRef ? '<definedNames><definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">' + esc("'" + sheetName.replace(/'/g, "''") + "'!" + filterRef) + '</definedName></definedNames>' : '') +
99
+ '</workbook>';
100
+ }
101
+
102
+ /** Excel refuses a sheet name over 31 characters or with any of : \ / ? * [ ] */
103
+ function sheetNameOf(title) {
104
+ var name = String(title || 'Sheet1').replace(/[:\\/?*[\]]/g, ' ').trim().slice(0, 31);
105
+ return name || 'Sheet1';
106
+ }
107
+
108
+ module.exports = {
109
+ requires: ['fflate'],
110
+ MAX_ROWS: MAX_ROWS,
111
+
112
+ /**
113
+ * @param {string} filePath
114
+ * @param {object} doc - { title?, subtitle?, columns: [{ header, kind?: 'number'|'date'|'text'|'raw', numFmt?, width?, align? }] }
115
+ * kind 'number': the value is written as a number (numFmt applies);
116
+ * kind 'date': a Date or ISO string, written as a date serial (numFmt applies);
117
+ * anything else: a number stays a number, everything else is text.
118
+ * @returns {{ writeRow(row): Promise<boolean>, close(): Promise<{ rows, truncated }> }}
119
+ * row: { values: [], kind?: 'detail'|'subtotal'|'total' }. writeRow answers
120
+ * false once Excel's row limit is reached (the rest are counted, not written).
121
+ */
122
+ open: function(filePath, doc) {
123
+ var fflate = require('fflate');
124
+ var columns = doc.columns || [];
125
+ var out = fs.createWriteStream(filePath);
126
+ var failed = null;
127
+ out.on('error', function(err) { failed = err; });
128
+ var zipDone;
129
+ var zipFinished = new Promise(function(resolve) { zipDone = resolve; });
130
+ var zip = new fflate.Zip(function(err, chunk, final) {
131
+ if (err) { failed = err; return; }
132
+ out.write(Buffer.from(chunk));
133
+ if (final) zipDone();
134
+ });
135
+ function addFile(name, text) {
136
+ var f = new fflate.ZipDeflate(name, { level: 6 });
137
+ zip.add(f);
138
+ f.push(fflate.strToU8(text), true);
139
+ }
140
+
141
+ // Styles are collected as the rows use them and written at the end.
142
+ var numFmts = [];
143
+ var xfs = [{ numFmtId: 0, fontId: 0, fillId: 0, borderId: 0 }];
144
+ var xfIndex = { '0|0|0|0|': 0 };
145
+ function styleId(numFmt, fontId, fillId, borderId, align) {
146
+ var numFmtId = 0;
147
+ if (numFmt) {
148
+ var at = numFmts.indexOf(numFmt);
149
+ if (at < 0) { numFmts.push(numFmt); at = numFmts.length - 1; }
150
+ numFmtId = 164 + at;
151
+ }
152
+ var key = numFmtId + '|' + fontId + '|' + fillId + '|' + borderId + '|' + (align || '');
153
+ if (xfIndex[key] === undefined) {
154
+ xfs.push({ numFmtId: numFmtId, fontId: fontId, fillId: fillId, borderId: borderId, align: align || null });
155
+ xfIndex[key] = xfs.length - 1;
156
+ }
157
+ return xfIndex[key];
158
+ }
159
+ var LOOK = {
160
+ detail: { font: FONT.normal, fill: FILL.none, border: 0 },
161
+ subtotal: { font: FONT.bold, fill: FILL.none, border: 0 },
162
+ total: { font: FONT.bold, fill: FILL.total, border: 1 }
163
+ };
164
+
165
+ var sheet = new fflate.ZipDeflate('xl/worksheets/sheet1.xml', { level: 6 });
166
+ var buffered = '';
167
+ var rowNo = 0;
168
+ var written = 0; // data rows written
169
+ var offered = 0; // data rows asked for
170
+ var headerRow = 0;
171
+ var sample = [];
172
+ var started = false;
173
+
174
+ function cellXml(ref, value, column, look) {
175
+ if (value === null || value === undefined || value === '') return '';
176
+ var kind = column.kind;
177
+ var align = column.align === 'right' || column.align === 'center' ? column.align : null;
178
+ if (kind === 'date') {
179
+ var serial = dateSerial(value);
180
+ if (serial !== null) return '<c r="' + ref + '" s="' + styleId(column.numFmt || 'yyyy-mm-dd', look.font, look.fill, look.border, align) + '"><v>' + serial + '</v></c>';
181
+ }
182
+ var num = typeof value === 'number' ? value : (kind === 'number' && value !== '' && !isNaN(Number(value)) ? Number(value) : null);
183
+ if (num !== null && isFinite(num)) {
184
+ return '<c r="' + ref + '" s="' + styleId(kind === 'number' ? column.numFmt : null, look.font, look.fill, look.border, align) + '"><v>' + num + '</v></c>';
185
+ }
186
+ if (typeof value === 'boolean') return '<c r="' + ref + '" t="b" s="' + styleId(null, look.font, look.fill, look.border, align) + '"><v>' + (value ? 1 : 0) + '</v></c>';
187
+ return '<c r="' + ref + '" t="inlineStr" s="' + styleId(null, look.font, look.fill, look.border, align) + '"><is><t xml:space="preserve">' + esc(value) + '</t></is></c>';
188
+ }
189
+
190
+ function textRow(cells) { rowNo += 1; return '<row r="' + rowNo + '">' + cells + '</row>'; }
191
+
192
+ async function push(text, final) {
193
+ buffered += text;
194
+ if (buffered.length < CHUNK && !final) return;
195
+ sheet.push(fflate.strToU8(buffered), Boolean(final));
196
+ buffered = '';
197
+ if (failed) throw failed;
198
+ if (out.writableNeedDrain) await new Promise(function(r) { out.once('drain', r); });
199
+ }
200
+
201
+ // <cols> needs the widths, which need a look at the rows: written once
202
+ // the sample is full, or at close for a short table.
203
+ async function start() {
204
+ started = true;
205
+ var widths = columns.map(function(c, i) {
206
+ if (c.width) return c.width;
207
+ var longest = String(c.header || '').length;
208
+ // A date is as wide as its pattern, not as its JS string form.
209
+ if (c.kind === 'date') return Math.min(60, Math.max(10, longest, String(c.numFmt || 'yyyy-mm-dd').length) + 2);
210
+ sample.forEach(function(r) {
211
+ var shown = r.display ? r.display[i] : r.values[i];
212
+ if (shown !== null && shown !== undefined) longest = Math.max(longest, String(shown).length);
213
+ });
214
+ return Math.min(60, Math.max(8, longest + 2));
215
+ });
216
+ zip.add(sheet);
217
+ var head = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
218
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
219
+ var titleRows = (doc.title ? 1 : 0) + (doc.subtitle ? 1 : 0);
220
+ headerRow = titleRows + (titleRows ? 2 : 1);
221
+ head += '<sheetViews><sheetView workbookViewId="0"><pane ySplit="' + headerRow + '" topLeftCell="A' + (headerRow + 1) +
222
+ '" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>';
223
+ head += '<cols>' + widths.map(function(w, i) { return '<col min="' + (i + 1) + '" max="' + (i + 1) + '" width="' + w + '" customWidth="1"/>'; }).join('') + '</cols>';
224
+ head += '<sheetData>';
225
+ if (doc.title) head += textRow('<c r="A' + (rowNo + 1) + '" t="inlineStr" s="' + styleId(null, FONT.title, 0, 0) + '"><is><t xml:space="preserve">' + esc(doc.title) + '</t></is></c>');
226
+ if (doc.subtitle) head += textRow('<c r="A' + (rowNo + 1) + '" t="inlineStr"><is><t xml:space="preserve">' + esc(doc.subtitle) + '</t></is></c>');
227
+ if (titleRows) rowNo += 1; // a blank row between the title block and the table
228
+ head += textRow(columns.map(function(c, i) {
229
+ var align = c.align === 'right' || c.align === 'center' ? c.align : null;
230
+ return '<c r="' + colName(i) + (rowNo + 1) + '" t="inlineStr" s="' + styleId(null, FONT.bold, FILL.header, 0, align) + '"><is><t xml:space="preserve">' + esc(c.header == null ? '' : c.header) + '</t></is></c>';
231
+ }).join(''));
232
+ await push(head);
233
+ var held = sample;
234
+ sample = null;
235
+ for (var k = 0; k < held.length; k++) await emit(held[k]);
236
+ }
237
+
238
+ async function emit(row) {
239
+ if (rowNo >= MAX_ROWS) return false;
240
+ var look = LOOK[row.kind] || LOOK.detail;
241
+ var r = rowNo + 1;
242
+ var cells = '';
243
+ var values = row.values || [];
244
+ for (var i = 0; i < columns.length; i++) cells += cellXml(colName(i) + r, values[i], columns[i], look);
245
+ written += 1;
246
+ await push(textRow(cells));
247
+ return true;
248
+ }
249
+
250
+ return {
251
+ writeRow: async function(row) {
252
+ offered += 1;
253
+ if (!started) {
254
+ sample.push(row);
255
+ if (sample.length >= SAMPLE_ROWS) await start();
256
+ return true;
257
+ }
258
+ return emit(row);
259
+ },
260
+ close: async function() {
261
+ if (!started) await start();
262
+ var lastRow = Math.max(rowNo, headerRow);
263
+ var filterRef = columns.length ? 'A' + headerRow + ':' + colName(columns.length - 1) + lastRow : null;
264
+ await push('</sheetData>' + (filterRef ? '<autoFilter ref="' + filterRef + '"/>' : '') +
265
+ '<pageSetup orientation="' + (columns.length > 6 ? 'landscape' : 'portrait') + '"/></worksheet>', true);
266
+ var sheetName = sheetNameOf(doc.title);
267
+ Object.keys(STATIC).forEach(function(name) { addFile(name, STATIC[name]); });
268
+ addFile('xl/workbook.xml', workbookXml(sheetName, filterRef ? '$A$' + headerRow + ':$' + colName(columns.length - 1) + '$' + lastRow : null));
269
+ addFile('xl/styles.xml', stylesXml(numFmts, xfs));
270
+ zip.end();
271
+ await zipFinished;
272
+ await new Promise(function(resolve, reject) { out.end(function(err) { return err ? reject(err) : resolve(); }); });
273
+ if (failed) throw failed;
274
+ return { rows: written, truncated: offered > written };
275
+ }
276
+ };
277
+ }
278
+ };
@@ -28,6 +28,8 @@
28
28
  // errorTable, // defaults to `<targetTable>_import_errors`
29
29
  // batchDir, // where NDJSON files spool to. Defaults to os.tmpdir()/xeplr-actions
30
30
  // keepBatchFiles, // default false — clean up NDJSON files after success
31
+ // replaceFrom, // optional, staged only: { column } — the move deletes target
32
+ // // rows from the earliest staged value on first (see mergeStaged)
31
33
  // staged, // default false. true: batches land in <targetTable>__xeplr_staging
32
34
  // // and move into the target in ONE transaction at the end, so a
33
35
  // // load that stops or dies midway never touched the target.
@@ -247,7 +249,8 @@ async function upload(opts) {
247
249
  targetTable: opts.targetTable,
248
250
  columns: columns,
249
251
  primaryKeys: opts.primaryKeys,
250
- movementId: opts.movementId
252
+ movementId: opts.movementId,
253
+ replaceFrom: opts.replaceFrom
251
254
  });
252
255
  }
253
256
  await opts.metaStore.recordEnd(opts.movementId, {
@@ -370,7 +373,16 @@ function normalizeOptions(opts) {
370
373
  (process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions'));
371
374
  opts.keepBatchFiles = !!opts.keepBatchFiles;
372
375
  opts.staged = !!opts.staged;
376
+ // A driver that matches upsert keys only in a staged move (DuckDB: its
377
+ // written files keep no index) stages every upsert, asked or not.
378
+ if (opts.driver && opts.driver.stagesUpserts && (opts.primaryKeys || []).length) opts.staged = true;
373
379
  opts.writeTable = null;
380
+ opts.replaceFrom = opts.replaceFrom || null;
381
+ if (opts.replaceFrom) {
382
+ if (!opts.replaceFrom.column) throw new Error('upload: replaceFrom needs a column');
383
+ if ((opts.primaryKeys || []).length) throw new Error('upload: replaceFrom replaces a range, so it takes no primaryKeys');
384
+ opts.staged = true;
385
+ }
374
386
  if (opts.staged && typeof opts.driver.mergeStaged !== 'function') {
375
387
  throw new Error('upload: staged loads are not supported for this database — its driver has no mergeStaged');
376
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -16,7 +16,11 @@
16
16
  "http",
17
17
  "db",
18
18
  "file",
19
- "email"
19
+ "email",
20
+ "export",
21
+ "csv",
22
+ "xlsx",
23
+ "pdf"
20
24
  ],
21
25
  "author": "xeplr",
22
26
  "license": "MIT",
@@ -28,15 +32,17 @@
28
32
  "access": "public"
29
33
  },
30
34
  "dependencies": {
31
- "@xeplr/schema-handler": "^1.0.0",
32
- "@xeplr/expression-handler": "^2.0.0"
35
+ "@xeplr/expression-handler": "^2.0.0",
36
+ "@xeplr/schema-handler": "^1.0.0"
33
37
  },
34
38
  "peerDependencies": {
35
39
  "@duckdb/node-api": "1.5.5-r.3",
36
40
  "@xeplr/email": "^1.0.0",
37
41
  "@xeplr/utils": "^1.0.0",
38
42
  "csv-parse": "^7.0.2",
43
+ "dejavu-fonts-ttf": "^2.37.3",
39
44
  "exceljs": "^4.0.0",
45
+ "fflate": "^0.8.2",
40
46
  "imapflow": "^1.0.0",
41
47
  "knex": "^3.0.0",
42
48
  "mailparser": "^3.0.0",
@@ -44,6 +50,7 @@
44
50
  "mssql": "^11.0.0",
45
51
  "mysql2": "^3.0.0",
46
52
  "nodemailer": "^10.0.13",
53
+ "pdfkit": "^0.20.2",
47
54
  "pg": "^8.0.0",
48
55
  "saxes": "^6.0.0",
49
56
  "ssh2-sftp-client": "^11.0.0",
@@ -97,15 +104,27 @@
97
104
  },
98
105
  "@xeplr/email": {
99
106
  "optional": true
107
+ },
108
+ "fflate": {
109
+ "optional": true
110
+ },
111
+ "pdfkit": {
112
+ "optional": true
113
+ },
114
+ "dejavu-fonts-ttf": {
115
+ "optional": true
100
116
  }
101
117
  },
102
118
  "devDependencies": {
103
119
  "@duckdb/node-api": "^1.5.5-r.3",
104
120
  "@xeplr/utils": "^1.0.0",
105
121
  "csv-parse": "^7.0.2",
122
+ "dejavu-fonts-ttf": "^2.37.3",
106
123
  "express": "^4.0.0",
124
+ "fflate": "^0.8.3",
107
125
  "mssql": "^12.7.0",
108
126
  "mysql2": "^3.23.1",
127
+ "pdfkit": "^0.20.2",
109
128
  "pg": "^8.0.0",
110
129
  "saxes": "^6.0.0",
111
130
  "unzipper": "^0.12.0"