@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 +50 -1
- package/lib/builtins/data-operation.js +241 -0
- package/lib/builtins/index.js +2 -0
- package/package.json +4 -3
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|
package/lib/builtins/index.js
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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",
|