@xeplr/actions 1.1.16 → 1.1.17

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,7 @@ Everything else is an **optional peer**, required only when an action actually u
24
24
  | `unzipper`, `saxes` | `format: 'excel'` (.xlsx) |
25
25
  | `imapflow` | every inbound email action |
26
26
  | `mailparser` | `email-read` (unless `body: 'none'`), `email-download-attachments` |
27
- | `nodemailer` | `email-send` with its own connection |
27
+ | `nodemailer` (^10.0.13 — up to 10.0.5 has high-severity advisories) | `email-send` with its own connection |
28
28
  | `@xeplr/email` | `email-send` with `templateName` |
29
29
  | `knex` | the knex meta store |
30
30
  | `mongodb`, `ssh2-sftp-client`, `exceljs` | declared, but their drivers/sources are not implemented |
@@ -106,6 +106,7 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
106
106
 
107
107
  | key | action name | does |
108
108
  |---|---|---|
109
+ | `dataOperation` | `data-operation` | From a list of rows, make the list the next step needs — keep some rows, pick and rename columns, compute new ones, sort, optionally render. See [Data operation](#data-operation). |
109
110
  | `dbFetch` | `db-fetch` | Stream rows from a table (`mode: 'table'`) or SQL (`mode: 'query'`, `params`). Default `streaming_mode: true` → NDJSON file `{ filePath, format: 'jsonl', bytes, rows }`; `false` → `{ rows, rowCount }`. |
110
111
  | `dbPush` | `db-push` | Write `rows` or an NDJSON `filePath` into `targetTable` through the uploader. `primaryKeys` → upsert. |
111
112
  | `dbMove` | `db-move` | One window of rows from a source table, query or procedure into a target table in one streamed pass. See [db-move](#db-move). |
@@ -170,6 +171,54 @@ For `mode: 'table'` the source's declared column types (`getTableSchema`) are pa
170
171
 
171
172
  Message ids are IMAP UIDs **scoped to `folder`**, and a move changes the id — download and delete before moving.
172
173
 
174
+ ## Data operation
175
+
176
+ The step between steps. A fetch returns everything a table holds; an email
177
+ wants four columns, the active rows, sorted, and sometimes rendered. Without
178
+ this, each of those is a module somebody writes and maintains — and the third
179
+ is written differently from the first two.
180
+
181
+ ```js
182
+ await runAction({ name: 'data-operation', input: {
183
+ from: rows, // a list; one object is one row
184
+ where: 'status = "active" and [due] <= today()',
185
+ columns: ['name', { name: 'late', label: 'Days late', formula: 'datediff([due], today())' },
186
+ { name: 'id', hidden: true }],
187
+ sort: '-late',
188
+ format: true, title: 'Overdue'
189
+ } })
190
+ // → { rows, count, html, text }
191
+ ```
192
+
193
+ **Rows and a rendering, both.** `rows` and `count` are always there; `html`
194
+ and `text` only when `format` is on, because building a table for ten
195
+ thousand rows nobody renders is work done and stored on every run. Neither is
196
+ privileged — the next step binds whichever it wants.
197
+
198
+ **`count` is the point of the step as often as `rows` is**: it is what lets
199
+ the step after it branch on *nothing to report* instead of sending an empty
200
+ email.
201
+
202
+ **Formulas are `@xeplr/expression-handler`** — the engine running BI's report
203
+ formulas and workflow's conditions, with the same functions. `where` and a
204
+ computed column are the same language.
205
+
206
+ **What `today()` means comes from the run's context**, not from the action:
207
+ `context.formula` carries `today` and `fiscalYearStart`, so a workspace that
208
+ reckons days elsewhere and a run replayed from history agree on what
209
+ `yesterday()` filtered. The action never calls `new Date()`.
210
+
211
+ **Only visible columns are rendered.** A column kept for the next step but
212
+ marked `hidden` stays in `rows` and never reaches the table — an id the flow
213
+ needs is not a column a person wants printed.
214
+
215
+ **A ragged row does not fail the step.** A column missing from one row reads
216
+ as nothing (`missing: 'null'`), because one bad row out of nine hundred
217
+ should not take the other eight hundred and ninety-nine with it.
218
+
219
+ **No aggregation.** `group by status, count` changes what a row *is*; that is
220
+ a different shape and, when it is wanted, a different step.
221
+
173
222
  ## Uploader
174
223
 
175
224
  `uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
@@ -0,0 +1,241 @@
1
+ // data-operation action — from what a step got, make what the next one needs.
2
+ //
3
+ // The step between steps. A fetch returns everything a table holds; an email
4
+ // wants four columns, only the active rows, sorted, and sometimes rendered.
5
+ // Without this every one of those is a module somebody writes and maintains,
6
+ // and the third one is written slightly differently from the first two.
7
+ //
8
+ // ROWS AND A RENDERING, BOTH. `rows` is always there and `html` only when
9
+ // asked for, and neither is privileged: the next step binds
10
+ // {steps.x.output.rows} or {steps.x.output.html} and the consumer decides,
11
+ // the same rule that settled the list/item-wise question.
12
+ //
13
+ // NO AGGREGATION. `group by status, count` changes what a row IS, which is a
14
+ // different shape and a different step. Filtering, picking, computing and
15
+ // sorting all leave a list of rows a list of rows.
16
+ //
17
+ // FORMULAS ARE @xeplr/expression-handler — the engine already running BI's
18
+ // report formulas and workflow's conditions. `where` and a computed column
19
+ // are the same language, with the same functions, evaluated with the run's
20
+ // own context: what `today()` means, and therefore what `yesterday()` means,
21
+ // is that engine's business and not this action's.
22
+
23
+ var xf = require('@xeplr/expression-handler');
24
+
25
+ /** A cell as it goes into HTML: never as markup. */
26
+ function escapeHtml(value) {
27
+ if (value === null || value === undefined) return '';
28
+ return String(value)
29
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
31
+ }
32
+
33
+ /** What a column is called in the output, and what its header reads. */
34
+ function columnSpec(col) {
35
+ if (typeof col === 'string') return { name: col.trim(), label: col.trim(), formula: null };
36
+ if (!col || !col.name) return null;
37
+ return {
38
+ name: String(col.name).trim(),
39
+ label: String(col.label || col.name).trim(),
40
+ formula: col.formula ? String(col.formula) : null,
41
+ // Hidden columns are computed and kept in `rows` — a later step may want
42
+ // an id it does not want printed — but are not in the rendering. See the
43
+ // note on `format` below.
44
+ hidden: Boolean(col.hidden)
45
+ };
46
+ }
47
+
48
+ /**
49
+ * A formula, ready to run against a row.
50
+ *
51
+ * The RUN'S CONTEXT is compiled in — what today is, and when the financial
52
+ * year starts. A workspace whose day starts elsewhere, and a run replayed
53
+ * from history, have to agree on what `yesterday()` meant, and that answer
54
+ * belongs to the engine and the context rather than to this action.
55
+ *
56
+ * `missing: 'null'` — a column that is not on a row reads as nothing, not as
57
+ * an error. Rows out of a real system are ragged, and one row missing a field
58
+ * should not fail the step that was filtering the other nine hundred.
59
+ */
60
+ function compile(formula, where, ctx) {
61
+ try {
62
+ return xf.compile(formula, Object.assign({ missing: 'null' }, ctx));
63
+ } catch (err) {
64
+ var e = new Error('Cannot read ' + where + ': ' + err.message);
65
+ e.status = 400;
66
+ throw e;
67
+ }
68
+ }
69
+
70
+ module.exports = {
71
+ name: 'data-operation',
72
+ description: 'From a list of rows, make the list the next step needs: keep some rows, pick and ' +
73
+ 'rename columns, compute new ones, sort — and optionally render an HTML table.',
74
+ requires: [],
75
+
76
+ inputSchema: [
77
+ { name: 'from', type: 'array', required: true, order: 1,
78
+ description: 'The rows to work on — usually an earlier step\'s output. One object is treated as one row.' },
79
+
80
+ { name: 'where', type: 'string', order: 2,
81
+ description: 'Keep only the rows this is true for, e.g. status = "active" and createdDate >= yesterday(). ' +
82
+ 'Blank keeps every row.' },
83
+
84
+ // A list rather than a comma-separated string: a computed column needs a
85
+ // formula and a label beside its name, and three parallel strings that
86
+ // have to line up is a shape nobody can edit by hand.
87
+ { name: 'columns', type: 'array', order: 3,
88
+ description: 'Which columns to keep, in order: "name", or { name, label, formula, hidden }. ' +
89
+ 'Blank keeps every column of the first row, as it is.' },
90
+
91
+ { name: 'sort', type: 'string', order: 4,
92
+ description: 'A column to sort by. Put - in front for descending, e.g. -dueDate.' },
93
+
94
+ // OFF BY DEFAULT. Building a table for ten thousand rows nobody renders
95
+ // is work done and stored on every run — so it is asked for rather than
96
+ // assumed, and the rows are there either way.
97
+ { name: 'format', type: 'boolean', default: false, order: 5,
98
+ description: 'Also give back an HTML table (and a plain-text one) of the visible columns.' },
99
+ { name: 'title', type: 'string', order: 6, group: 'Formatting',
100
+ description: 'A heading above the table.' },
101
+ { name: 'emptyText', type: 'string', order: 7, group: 'Formatting',
102
+ description: 'What the table says when no rows are left. Default: "Nothing to show".' }
103
+ ],
104
+
105
+ outputSchema: [
106
+ { name: 'rows', type: 'array', description: 'What is left, with the columns asked for.' },
107
+ { name: 'count', type: 'number', description: 'How many rows — so the next step can branch on none.' },
108
+ { name: 'html', type: 'string', description: 'A table of the visible columns. Only when `format` is on.' },
109
+ { name: 'text', type: 'string', description: 'The same, as plain text, for a mail client that refuses HTML.' }
110
+ ],
111
+
112
+ execute: async function(input, context) {
113
+ var given = input.from;
114
+ // A list, always — one object is a list of one, and nothing is an empty
115
+ // list rather than an error. The step after this one should not have to
116
+ // ask which it got.
117
+ var source = given === null || given === undefined ? []
118
+ : Array.isArray(given) ? given
119
+ : [given];
120
+
121
+ // The run's own context decides what today is — and therefore what
122
+ // yesterday is. See the note at the top.
123
+ var ctx = (context && context.formula) || {};
124
+
125
+ var keep = null;
126
+ if (input.where && String(input.where).trim()) {
127
+ var test = compile(String(input.where), 'the condition', ctx);
128
+ // Anything the formula calls true. `status = "active"` gives a boolean,
129
+ // but `daysLate` gives a number, and a step that filtered on it would
130
+ // otherwise keep every row including the zeroes.
131
+ keep = function(row) {
132
+ var v = test(row);
133
+ return v !== null && v !== undefined && v !== false && v !== 0 && v !== '';
134
+ };
135
+ }
136
+
137
+ var specs = (Array.isArray(input.columns) ? input.columns : [])
138
+ .map(columnSpec)
139
+ .filter(Boolean);
140
+
141
+ // No columns asked for: every column of the data, as it is. A step that
142
+ // only filters should not have to list what it is not changing.
143
+ if (!specs.length) {
144
+ var first = source.find(function(r) { return r && typeof r === 'object'; });
145
+ specs = Object.keys(first || {}).map(function(k) { return { name: k, label: k, formula: null }; });
146
+ }
147
+
148
+ var computed = specs.map(function(spec) {
149
+ return spec.formula ? Object.assign({}, spec, { fn: compile(spec.formula, '"' + spec.label + '"', ctx) }) : spec;
150
+ });
151
+
152
+ var rows = [];
153
+ for (var i = 0; i < source.length; i++) {
154
+ var row = source[i] && typeof source[i] === 'object' ? source[i] : {};
155
+ if (keep && !keep(row)) continue;
156
+
157
+ var out = {};
158
+ for (var c = 0; c < computed.length; c++) {
159
+ var spec = computed[c];
160
+ out[spec.name] = spec.fn ? spec.fn(row) : row[spec.name];
161
+ }
162
+ rows.push(out);
163
+ }
164
+
165
+ if (input.sort && String(input.sort).trim()) {
166
+ var desc = String(input.sort).trim().charAt(0) === '-';
167
+ var by = desc ? String(input.sort).trim().slice(1) : String(input.sort).trim();
168
+ rows.sort(function(a, b) {
169
+ var x = a[by], y = b[by];
170
+ if (x === y) return 0;
171
+ // Nothing sorts last either way: an empty cell is not the smallest
172
+ // value, it is the absence of one, and burying it under the top of a
173
+ // descending list is how it gets missed.
174
+ if (x === null || x === undefined || x === '') return 1;
175
+ if (y === null || y === undefined || y === '') return -1;
176
+ var out = x < y ? -1 : 1;
177
+ return desc ? -out : out;
178
+ });
179
+ }
180
+
181
+ var result = { rows: rows, count: rows.length };
182
+
183
+ if (input.format) {
184
+ // THE VISIBLE COLUMNS ONLY. A column kept for the next step but not
185
+ // meant to be printed is not printed — and its label, which exists for
186
+ // a header, goes with it.
187
+ var shown = computed.filter(function(s) { return !s.hidden; });
188
+ var empty = input.emptyText || 'Nothing to show';
189
+ result.html = toHtml(rows, shown, input.title, empty);
190
+ result.text = toText(rows, shown, input.title, empty);
191
+ }
192
+
193
+ return result;
194
+ }
195
+ };
196
+
197
+ function toHtml(rows, columns, title, empty) {
198
+ var head = '<tr>' + columns.map(function(c) {
199
+ return '<th align="left" style="padding:6px 10px;border-bottom:1px solid #ddd">' + escapeHtml(c.label) + '</th>';
200
+ }).join('') + '</tr>';
201
+
202
+ var body = rows.map(function(r) {
203
+ return '<tr>' + columns.map(function(c) {
204
+ return '<td style="padding:6px 10px;border-bottom:1px solid #f0f0f0">' + escapeHtml(r[c.name]) + '</td>';
205
+ }).join('') + '</tr>';
206
+ }).join('');
207
+
208
+ // Inline styles and no stylesheet: an email client strips <style> and most
209
+ // of what it does not strip, it ignores.
210
+ var table = rows.length
211
+ ? '<table cellspacing="0" cellpadding="0" style="border-collapse:collapse;font-family:sans-serif;font-size:14px">' +
212
+ '<thead>' + head + '</thead><tbody>' + body + '</tbody></table>'
213
+ : '<p style="font-family:sans-serif;font-size:14px">' + escapeHtml(empty) + '</p>';
214
+
215
+ return title ? '<h3 style="font-family:sans-serif;font-size:15px;margin:0 0 8px">' + escapeHtml(title) + '</h3>' + table : table;
216
+ }
217
+
218
+ function toText(rows, columns, title, empty) {
219
+ if (!rows.length) return (title ? title + '\n\n' : '') + empty;
220
+
221
+ var cells = [columns.map(function(c) { return String(c.label); })].concat(
222
+ rows.map(function(r) {
223
+ return columns.map(function(c) { return r[c.name] === null || r[c.name] === undefined ? '' : String(r[c.name]); });
224
+ })
225
+ );
226
+
227
+ // Padded to the widest cell in each column, so it still reads as a table in
228
+ // a client that shows plain text in a proportional font badly.
229
+ var widths = columns.map(function(_, i) {
230
+ return cells.reduce(function(max, line) { return Math.max(max, line[i].length); }, 0);
231
+ });
232
+ var line = function(parts) {
233
+ return parts.map(function(p, i) { return p.padEnd(widths[i]); }).join(' ').trimEnd();
234
+ };
235
+
236
+ var out = [line(cells[0]), widths.map(function(w) { return '-'.repeat(w); }).join(' ')]
237
+ .concat(cells.slice(1).map(line))
238
+ .join('\n');
239
+
240
+ return title ? title + '\n\n' + out : out;
241
+ }
@@ -18,6 +18,8 @@
18
18
  module.exports = {
19
19
  // Generic actions (no runtime-config dependency)
20
20
  httpRequest: require('./http-request'),
21
+ // The step between steps: from what one step got, make what the next needs.
22
+ dataOperation: require('./data-operation'),
21
23
  spawnProgram: require('./spawn-program'),
22
24
  sendEmail: require('./send-email'),
23
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
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": [
@@ -28,7 +28,8 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@xeplr/schema-handler": "^1.0.0"
31
+ "@xeplr/schema-handler": "^1.0.0",
32
+ "@xeplr/expression-handler": "^2.0.0"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "@duckdb/node-api": "1.5.5-r.3",
@@ -42,7 +43,7 @@
42
43
  "mongodb": "^6.0.0",
43
44
  "mssql": "^11.0.0",
44
45
  "mysql2": "^3.0.0",
45
- "nodemailer": "^8.0.2",
46
+ "nodemailer": "^10.0.13",
46
47
  "pg": "^8.0.0",
47
48
  "saxes": "^6.0.0",
48
49
  "ssh2-sftp-client": "^11.0.0",