@xeplr/actions 1.4.4 → 1.4.6
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 +15 -1
- package/index.js +5 -0
- package/lib/builtins/data-operation.js +12 -2
- package/lib/connections.js +104 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ await runAction({ name: 'data-operation', input: {
|
|
|
191
191
|
sort: '-late',
|
|
192
192
|
format: true, title: 'Overdue'
|
|
193
193
|
} })
|
|
194
|
-
// → { rows, count, html, text }
|
|
194
|
+
// → { rows, count, columns, html, text }
|
|
195
195
|
```
|
|
196
196
|
|
|
197
197
|
**Rows and a rendering, both.** `rows` and `count` are always there; `html`
|
|
@@ -199,6 +199,8 @@ and `text` only when `format` is on, because building a table for ten
|
|
|
199
199
|
thousand rows nobody renders is work done and stored on every run. Neither is
|
|
200
200
|
privileged — the next step binds whichever it wants.
|
|
201
201
|
|
|
202
|
+
**`columns` says the order and the headings** (`[{ key, header }]`, the visible ones), in `export-table`'s column shape, so a workflow writes `document: { columns: "{steps.overdue.output.columns}" }`. Why: a step's output is stored as JSONB, which reorders a row's keys (shortest first), so the next step cannot read the column order off a row. A file exported without it came out as `late, amount, invoice, customer`.
|
|
203
|
+
|
|
202
204
|
**`count` is the point of the step as often as `rows` is**: it is what lets
|
|
203
205
|
the step after it branch on *nothing to report* instead of sending an empty
|
|
204
206
|
email.
|
|
@@ -231,6 +233,18 @@ should not take the other eight hundred and ninety-nine with it.
|
|
|
231
233
|
**No aggregation.** `group by status, count` changes what a row *is*; that is
|
|
232
234
|
a different shape and, when it is wanted, a different step.
|
|
233
235
|
|
|
236
|
+
## Saved connections: an id in the definition, the login only in the call
|
|
237
|
+
|
|
238
|
+
A job or a workflow step stores `connectionInfoId`, never a password. `resolveConnections(inputs, resolver, who?)` swaps each id for the login just before the action runs, in a **new** object, so what the run records still holds only the id. `@xeplr/jobs` and `@xeplr/workflow-engine` both use it.
|
|
239
|
+
|
|
240
|
+
| field in the definition | filled for the action |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `connectionInfoId` | `connection` (and `dbType`, when the resolver says) |
|
|
243
|
+
| `sourceConnectionInfoId` / `targetConnectionInfoId` | `sourceConnection` / `targetConnection` (and `…DbType`) |
|
|
244
|
+
|
|
245
|
+
- **`resolver(id, { field, inputs })`** is the host's: a login, or `{ dbType, connection }`. It sees all the inputs, for a companion id such as BI's `dbInfoId`.
|
|
246
|
+
- **An id that resolves to nothing is refused** (`CONNECTION_NOT_FOUND`), never replaced by a literal `connection`. An id with no resolver configured: `NO_CONNECTION_RESOLVER`.
|
|
247
|
+
|
|
234
248
|
## Saving files: names, and a name already taken
|
|
235
249
|
|
|
236
250
|
Every action that saves a file follows one rule (`lib/fileNames.js`): `email-download-attachments`, `email-download-email` and `export-table`.
|
package/index.js
CHANGED
|
@@ -22,6 +22,11 @@ module.exports = {
|
|
|
22
22
|
// runAction(nameOrAction, input, opts?)
|
|
23
23
|
runAction: runner.runAction,
|
|
24
24
|
|
|
25
|
+
// Saved connections: an id in the definition, the login only in the call.
|
|
26
|
+
// Shared by @xeplr/jobs and @xeplr/workflow-engine. See lib/connections.js.
|
|
27
|
+
resolveConnections: require('./lib/connections').resolveConnections,
|
|
28
|
+
connectionFieldFor: require('./lib/connections').connectionFieldFor,
|
|
29
|
+
|
|
25
30
|
// Errors
|
|
26
31
|
ActionNotRegisteredError: errors.ActionNotRegisteredError,
|
|
27
32
|
ActionMissingDependencyError: errors.ActionMissingDependencyError,
|
|
@@ -106,7 +106,9 @@ module.exports = {
|
|
|
106
106
|
{ name: 'rows', type: 'array', description: 'What is left, with the columns asked for.' },
|
|
107
107
|
{ name: 'count', type: 'number', description: 'How many rows — so the next step can branch on none.' },
|
|
108
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.' }
|
|
109
|
+
{ name: 'text', type: 'string', description: 'The same, as plain text, for a mail client that refuses HTML.' },
|
|
110
|
+
{ name: 'columns', type: 'array', description: 'The visible columns, in order: [{ key, header }]. Give it to export-table ' +
|
|
111
|
+
'as document.columns: a step\'s stored output does not keep its fields in order.' }
|
|
110
112
|
],
|
|
111
113
|
|
|
112
114
|
// The same call every action gets from runAction: { input, system }. It
|
|
@@ -183,7 +185,15 @@ module.exports = {
|
|
|
183
185
|
});
|
|
184
186
|
}
|
|
185
187
|
|
|
186
|
-
|
|
188
|
+
// THE ORDER AND THE HEADINGS, said out loud. A step's output is stored
|
|
189
|
+
// as JSONB, which reorders an object's keys (shortest first), so the next
|
|
190
|
+
// step cannot read the column order off a row. This is export-table's
|
|
191
|
+
// column shape, so it is handed over as it is.
|
|
192
|
+
var result = {
|
|
193
|
+
rows: rows,
|
|
194
|
+
count: rows.length,
|
|
195
|
+
columns: computed.filter(function(c) { return !c.hidden; }).map(function(c) { return { key: c.name, header: c.label }; })
|
|
196
|
+
};
|
|
187
197
|
|
|
188
198
|
if (input.format) {
|
|
189
199
|
// THE VISIBLE COLUMNS ONLY. A column kept for the next step but not
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SAVED CONNECTIONS: AN ID IN THE STEP, THE LOGIN ONLY IN THE CALL.
|
|
2
|
+
//
|
|
3
|
+
// An action like db-fetch takes `connection: { host, port, user, password,
|
|
4
|
+
// database }`. A job or a workflow step cannot store that, and a person
|
|
5
|
+
// cannot type it:
|
|
6
|
+
//
|
|
7
|
+
// - The browser must never see it. A host seals credentials and decrypts
|
|
8
|
+
// them only at the point of use.
|
|
9
|
+
// - What a run records must never hold it. A job's inputs are snapshotted
|
|
10
|
+
// on every occurrence, and a workflow step's input on every step run, so
|
|
11
|
+
// one password in a definition becomes one password in a hundred rows.
|
|
12
|
+
//
|
|
13
|
+
// So the definition stores `connectionInfoId` (an id, safe everywhere), and
|
|
14
|
+
// the id is exchanged for the real login HERE, moments before the action is
|
|
15
|
+
// called, only in the object handed to that call. One copy, used by
|
|
16
|
+
// @xeplr/jobs and @xeplr/workflow-engine.
|
|
17
|
+
//
|
|
18
|
+
// ONE HOOK, not one per action: the host says once how an id becomes a
|
|
19
|
+
// connection (`resolver`), and every action taking a connection benefits.
|
|
20
|
+
// The convention is the field NAME:
|
|
21
|
+
//
|
|
22
|
+
// connectionInfoId → connection
|
|
23
|
+
// sourceConnectionInfoId → sourceConnection
|
|
24
|
+
// targetConnectionInfoId → targetConnection
|
|
25
|
+
//
|
|
26
|
+
// so an action with two connections needs no configuration to get both.
|
|
27
|
+
|
|
28
|
+
var CONNECTION_ID_SUFFIX = /ConnectionInfoId$/;
|
|
29
|
+
|
|
30
|
+
/** The field a connection id fills: connectionInfoId → connection. null for any other field. */
|
|
31
|
+
function connectionFieldFor(key) {
|
|
32
|
+
if (key === 'connectionInfoId') return 'connection';
|
|
33
|
+
if (!CONNECTION_ID_SUFFIX.test(key)) return null;
|
|
34
|
+
return key.slice(0, -'ConnectionInfoId'.length) + 'Connection';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Swap every *ConnectionInfoId in `inputs` for the login it names.
|
|
39
|
+
*
|
|
40
|
+
* Returns a NEW object: the caller's inputs are left untouched, which is what
|
|
41
|
+
* keeps a run's record free of secrets.
|
|
42
|
+
*
|
|
43
|
+
* A MISSING RESOLUTION IS FATAL, deliberately. If a definition says "use
|
|
44
|
+
* connection X" and X has been deleted, belongs to another tenant, or cannot
|
|
45
|
+
* be decrypted because the key was rotated, the only safe answer is to stop.
|
|
46
|
+
* Falling back to a literal `connection` sitting in the inputs would connect
|
|
47
|
+
* to something the author did not ask for, and it would work. A literal
|
|
48
|
+
* connection is honoured only when no id was given at all.
|
|
49
|
+
*
|
|
50
|
+
* Call it inside the run's tenant context: a host's saved connections are
|
|
51
|
+
* usually tenant-scoped, and resolving outside one finds nothing.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} inputs
|
|
54
|
+
* @param {Function} resolver async (id, { field, inputs }) → a login, or { dbType, connection }
|
|
55
|
+
* @param {string} [who] how the refusal names the caller ("This job", "Step fetch")
|
|
56
|
+
*/
|
|
57
|
+
async function resolveConnections(inputs, resolver, who) {
|
|
58
|
+
var raw = inputs || {};
|
|
59
|
+
var ids = Object.keys(raw).filter(function(k) { return connectionFieldFor(k) && raw[k]; });
|
|
60
|
+
if (!ids.length) return raw;
|
|
61
|
+
var name = who || 'This';
|
|
62
|
+
|
|
63
|
+
if (typeof resolver !== 'function') {
|
|
64
|
+
var none = new Error(name + ' names a saved connection (' + ids.join(', ') +
|
|
65
|
+
'), but no connection resolver is configured. The host passes resolveConnection.');
|
|
66
|
+
none.code = 'NO_CONNECTION_RESOLVER';
|
|
67
|
+
throw none;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var out = Object.assign({}, raw);
|
|
71
|
+
for (var i = 0; i < ids.length; i++) {
|
|
72
|
+
var key = ids[i];
|
|
73
|
+
var target = connectionFieldFor(key);
|
|
74
|
+
|
|
75
|
+
// THE WHOLE INPUTS go to the resolver, not just the one id: a saved
|
|
76
|
+
// connection is not always one id. BI's takes (connectionId, dbInfoId),
|
|
77
|
+
// the server and then which database on it.
|
|
78
|
+
var resolved = await resolver(raw[key], { field: key, inputs: raw });
|
|
79
|
+
if (!resolved) {
|
|
80
|
+
var missing = new Error('Connection "' + raw[key] + '" (' + key + ') could not be resolved: ' +
|
|
81
|
+
'it may have been deleted, or belong to another company. Refusing to run ' +
|
|
82
|
+
'rather than fall back to a different connection.');
|
|
83
|
+
missing.code = 'CONNECTION_NOT_FOUND';
|
|
84
|
+
throw missing;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Two accepted shapes: a bare login, or { dbType, connection }. The
|
|
88
|
+
// second matters: the connection already knows whether it is postgres,
|
|
89
|
+
// mysql or mssql, so nobody has to choose that again.
|
|
90
|
+
if (resolved.connection) {
|
|
91
|
+
out[target] = resolved.connection;
|
|
92
|
+
var typeField = target === 'connection' ? 'dbType' : target + 'DbType';
|
|
93
|
+
if (resolved.dbType) out[typeField] = resolved.dbType;
|
|
94
|
+
} else {
|
|
95
|
+
out[target] = resolved;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The id goes no further. The action was never given one to interpret.
|
|
99
|
+
delete out[key];
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { resolveConnections: resolveConnections, connectionFieldFor: connectionFieldFor };
|
package/package.json
CHANGED