@xeplr/actions 2.0.0 → 2.2.0

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
@@ -64,10 +64,12 @@ var moved = await actions.runAction(actions.builtins.dbMove, {
64
64
  |---|---|
65
65
  | `register(def)` / `register(name, def)` | Add an action. Throws without `name` or `execute`, or with a `requires` module that is not installed (`ActionMissingDependencyError`). Re-registering a name replaces it. |
66
66
  | `get(name)` / `has(name)` | Look one up. |
67
- | `list()` | `[{ name, description, inputSchema, outputSchema, requires, event }]` — serializable, for an actions endpoint. `event` is `null` for an ordinary action. |
67
+ | `list()` | `[{ name, description, inputSchema, outputSchema, requires, event, preview, example }]` — serializable, for an actions endpoint. `event` is `null` for an ordinary action; `preview` is `true` when the action can be previewed (the function itself never leaves the process). |
68
68
  | `clear()` | Empty the registry. |
69
69
  | `runAction({ name, input, system?, timeoutMs? })` | Run a registered action. Never throws. |
70
70
  | `runAction(nameOrDef, input, { system, timeoutMs, metaStore }?)` | Same, by name or with an action module directly. |
71
+ | `previewAction({ name, input, system? })` / `previewAction(nameOrDef, input, opts?)` | What the step **would** hand on, without running it — see [Previews](#previews-what-a-step-would-hand-on). Never calls `execute`. Never throws. |
72
+ | `PREVIEW_ROWS` | `5`: how many rows or items a preview reads. |
71
73
  | `ActionNotRegisteredError` | Returned (in `error`) for an unknown name. |
72
74
  | `ActionMissingDependencyError` | Thrown by `register()` for a missing `requires` module. |
73
75
  | `TransientError` | For an action to signal "retry me". Nothing in this package (or `@xeplr/jobs`) acts on it yet. |
@@ -89,6 +91,8 @@ var moved = await actions.runAction(actions.builtins.dbMove, {
89
91
  | `description` | Text for pickers. |
90
92
  | `inputSchema` | `[{ name, type, required, default, description, order, ... }]`, validated by `@xeplr/schema-handler` `applySchema`. Built-ins also use `group`, `showWhen`, `system`, `options`, `optionsFrom`, `dependsOn`, `label`, `hidden` (left out of a form unless a value is already set), `suggestFrom` / `suggestWith` (a host option source that suggests values, asked with the named sibling inputs; `db-procedure`'s parameters ask `procedureParams` with the connection, database and procedure) and `control` (`'parameters'`: `[{ name, value }]` shown as rows, a value fixed or bound to the run's window: `{window.from}` / `{window.to}`, see `@xeplr/jobs`) — hints for a form; validation treats every field the same. |
91
93
  | `outputSchema` | Optional documentation of the return value. |
94
+ | `preview({ input, system })` | Optional. Resolves to `{ output, rows? }`: what `execute` would return, read **without a side effect** — at most `PREVIEW_ROWS` rows or items, never a write, a send, a move or a delete. Only for an action that can read safely. |
95
+ | `example` | Optional. For an action that writes or sends: its output with example values, so a designer can show the shape without running it. |
92
96
  | `requires` | Module names checked with `require.resolve` at `register()`. |
93
97
  | `execute({ input, system, metaStore })` | Returns (or resolves to) the output. |
94
98
  | `event` | Optional. Makes this a **screen function** rather than a step: the event it is written for — `beforeLoad`, `beforeSave`, `afterSave`, `beforeDelete`, `onError` (run by `@xeplr/factory` around a screen's records) or `decide` (run by `@xeplr/workflow-engine` to choose a flow's next step). A screen names one per event and pickers offer only the functions for that event. It is called with the event's context — `execute(ctx)`, not `{ input, system }` — and never as a workflow step. |
@@ -101,6 +105,26 @@ var moved = await actions.runAction(actions.builtins.dbMove, {
101
105
 
102
106
  An unknown action, invalid input, a thrown or rejected `execute`, and a timeout (`timeoutMs`, message `Action timed out after Nms`) all come back as `status: 'failed'`. A timeout does not cancel the running work.
103
107
 
108
+ ### Previews: what a step would hand on
109
+
110
+ A workflow designer shows a step's output before anything has run, so later steps can pick from it. Typing an example by hand was the only way, even where the output can simply be read. `previewAction` reads it instead, and **only ever calls an action's `preview`, never its `execute`**:
111
+
112
+ ```js
113
+ { status: 'success' | 'example' | 'failed', output, rows, error, durationMs }
114
+ ```
115
+
116
+ - `success`: `output` is what the step would hand on (its shape, sample values); `rows` the first few rows or items when it reads some.
117
+ - `example`: the action has no preview (it writes or sends); `output` is its declared `example`, or `null`. **Nothing ran.**
118
+ - `failed`: the input is incomplete or the read failed; `error` says which.
119
+
120
+ The input is checked exactly as a run checks it.
121
+
122
+ | previewed (reads, up to 5) | example only (writes or sends; never run) |
123
+ |---|---|
124
+ | `db-fetch` (through the run's own cursor, then stops; nothing spooled), `db-list-tables` / `-views` / `-procedures` / `-columns`, `file-list`, `file-read`, `data-operation` (the run itself: it only computes), `email-read` (the mailbox opened **read-only**, IMAP `EXAMINE`, so nothing is marked read) | `file-move`, `file-delete`, `email-send`, `email-move`, `email-delete`, `email-download-email`, `email-download-attachments`, `export-table`, `export-pages`, `db-procedure` (a procedure may write) |
125
+
126
+ Neither: `db-push`, `db-move`, `http-request` (an outside system, which may change something), `spawn-program`, `file-upload`; a designer asks for an example by hand. `test/previews.test.js` checks it on a real folder, file and Postgres: five rows read and no file spooled, the real count kept, a deleting step's file still there.
127
+
104
128
  ## Built-in actions
105
129
 
106
130
  Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a module straight to `runAction`. `@xeplr/jobs` registers all implemented ones except `spawnProgram` and `dbMove`.
@@ -302,7 +326,7 @@ Which inputs are checked: `file-list` `folder`; `file-read` / `file-delete` `pat
302
326
 
303
327
  **`email-send` attachments** are `{ filename, path }` (inside the folder) or `{ filename, content }`. `href` and `raw` are refused, and so is a body that is not text: the mail library reads `path`, `href` and `html: { path }` from disk or from the network, so an unchecked one would mail the server's own settings or fetch an internal address.
304
328
 
305
- `dataDir` is exported (`resolve(name)`, `tenantFolder()`, `scratch()`, `nameOf(path)`) for a host's own code that keeps files per tenant.
329
+ `dataDir` is exported (`resolve(name)`, `tenantFolder()`, `scratch()`, `nameOf(path)`) for a host's own code that keeps files per tenant. It is `@xeplr/db`'s (peer ^1.1.0), the one copy every package uses.
306
330
 
307
331
  **From 1.x:** `XEPLR_FILE_ROOTS` and `XEPLR_ACTIONS_TMP_DIR` are gone. Set `XEPLR_DATA_DIR`, call `registerApplication()`, and run actions inside a tenant context (jobs and the workflow engine already do). A saved path outside the tenant's folder is refused until its files are moved in.
308
332
 
package/index.js CHANGED
@@ -21,6 +21,10 @@ module.exports = {
21
21
  // runAction({ name, input, system?, timeoutMs? })
22
22
  // runAction(nameOrAction, input, opts?)
23
23
  runAction: runner.runAction,
24
+ // What a step would hand on, read without a side effect (an action's
25
+ // `preview`), or its declared example. Never runs `execute`.
26
+ previewAction: runner.previewAction,
27
+ PREVIEW_ROWS: runner.PREVIEW_ROWS,
24
28
 
25
29
  // Saved connections: an id in the definition, the login only in the call.
26
30
  // Shared by @xeplr/jobs and @xeplr/workflow-engine. See lib/connections.js.
@@ -114,6 +114,13 @@ module.exports = {
114
114
  // The same call every action gets from runAction: { input, system }. It
115
115
  // once took (input, context) — the runner's object landed in `input`,
116
116
  // `from` was undefined, and every workflow step returned no rows.
117
+ // Works only on the rows it is given — no database, no file, no send — so
118
+ // its preview is the run, over the sample rows the step before hands on.
119
+ preview: async function(call) {
120
+ var out = await module.exports.execute(call);
121
+ return { output: out, rows: (out && out.rows || []).slice(0, require('../runner').PREVIEW_ROWS) };
122
+ },
123
+
117
124
  execute: async function(call) {
118
125
  var input = (call && call.input) || {};
119
126
  var system = (call && call.system) || {};
@@ -79,6 +79,31 @@ module.exports = {
79
79
  description: 'Rows pulled per cursor round-trip / stream highWaterMark.' }
80
80
  ],
81
81
 
82
+ // THE FIRST ROWS, through the same statement and cursor a run uses, then
83
+ // stop: nothing is spooled. The output is what a run hands on, with the
84
+ // file it would write named but not written.
85
+ preview: async function(ctx) {
86
+ var input = ctx.input || {};
87
+ var driver = dbDrivers.getDriver(input.dbType);
88
+ dbDrivers.checkDriverRequires('db-fetch', input.dbType, driver);
89
+ var built = buildFetchSql(driver, input);
90
+ var pool = await driver.connect(input.connection);
91
+ var rows = [];
92
+ try {
93
+ var stream = driver.fetchStream(pool, { sql: built.sql, params: built.params, batchSize: require('../../runner').PREVIEW_ROWS });
94
+ for await (var row of stream) {
95
+ rows.push(row);
96
+ if (rows.length >= require('../../runner').PREVIEW_ROWS) break;
97
+ }
98
+ } finally {
99
+ await driver.close(pool);
100
+ }
101
+ var output = input.streaming_mode === false
102
+ ? { rows: rows, rowCount: rows.length, streaming: false }
103
+ : { filePath: '(a file in this company\'s tmp/, written when it runs)', format: 'jsonl', bytes: 0, rows: rows.length, streaming: true };
104
+ return { output: output, rows: rows };
105
+ },
106
+
82
107
  execute: async function(ctx) {
83
108
  var input = ctx.input || {};
84
109
  var system = ctx.system || {};
@@ -20,6 +20,9 @@ module.exports = {
20
20
  description: 'Schema the table lives in (postgres/mssql — default public/all; mysql ignores this in favor of connection.database)' }
21
21
  ],
22
22
 
23
+ // Only reads the database's own catalogue, so the preview is the run.
24
+ preview: async function(ctx) { return { output: await module.exports.execute(ctx) }; },
25
+
23
26
  execute: async function(ctx) {
24
27
  var input = ctx.input || {};
25
28
  var driver = dbDrivers.getDriver(input.dbType);
@@ -17,6 +17,9 @@ module.exports = {
17
17
  description: 'Schema to list (postgres/mssql — default public/all; mysql ignores this in favor of connection.database)' }
18
18
  ],
19
19
 
20
+ // Only reads the database's own catalogue, so the preview is the run.
21
+ preview: async function(ctx) { return { output: await module.exports.execute(ctx) }; },
22
+
20
23
  execute: async function(ctx) {
21
24
  var input = ctx.input || {};
22
25
  var driver = dbDrivers.getDriver(input.dbType);
@@ -19,6 +19,9 @@ module.exports = {
19
19
  description: 'Schema to list (postgres/mssql — default public/all; mysql ignores this in favor of connection.database)' }
20
20
  ],
21
21
 
22
+ // Only reads the database's own catalogue, so the preview is the run.
23
+ preview: async function(ctx) { return { output: await module.exports.execute(ctx) }; },
24
+
22
25
  execute: async function(ctx) {
23
26
  var input = ctx.input || {};
24
27
  var driver = dbDrivers.getDriver(input.dbType);
@@ -16,6 +16,9 @@ module.exports = {
16
16
  description: 'Schema to list (postgres/mssql — default public/all; mysql ignores this in favor of connection.database)' }
17
17
  ],
18
18
 
19
+ // Only reads the database's own catalogue, so the preview is the run.
20
+ preview: async function(ctx) { return { output: await module.exports.execute(ctx) }; },
21
+
19
22
  execute: async function(ctx) {
20
23
  var input = ctx.input || {};
21
24
  var driver = dbDrivers.getDriver(input.dbType);
@@ -32,6 +32,8 @@ var { buildProcedureCall } = require('../../drivers/db/procedure');
32
32
 
33
33
  module.exports = {
34
34
  name: 'db-procedure',
35
+ // What it hands on, with example values: it writes, so it is never previewed.
36
+ example: { rows: [{ id: 1, status: 'done' }], rowCount: 1, procedure: 'close_period', sql: 'CALL close_period(?)' },
35
37
  description: 'Call a stored procedure with parameters. Returns its rows when it produces any — ' +
36
38
  'no target table, no movement. For a procedure whose rows you want written to a ' +
37
39
  'table, use db-move with mode="procedure".',
@@ -13,6 +13,8 @@ var emailConfig = require('../../drivers/email/config');
13
13
 
14
14
  module.exports = {
15
15
  name: 'email-delete',
16
+ // What it hands on, with example values: it writes, so it is never previewed.
17
+ example: { deleted: true, messageId: '4182', folder: 'INBOX' },
16
18
  description: 'Permanently delete a message (flags \\Deleted and expunges — NOT a move to Trash). ' +
17
19
  'The message id is folder-scoped, so input.folder must name the folder it is in.',
18
20
  requires: [],
@@ -17,6 +17,8 @@ var fileNames = require('../../fileNames');
17
17
 
18
18
  module.exports = {
19
19
  name: 'email-download-attachments',
20
+ // What it hands on, with example values: it writes, so it is never previewed.
21
+ example: { count: 1, saved: [{ filename: 'invoice.pdf', path: 'mail/invoice.pdf', size: 91022, contentType: 'application/pdf' }], path: 'mail/invoice.pdf', existing: [], subject: 'Invoice 450103', messageId: '4182' },
20
22
  description: 'Save a message\'s attachments to disk, optionally filtered by extension or ' +
21
23
  'filename substring, named as you say. Returns { count, saved[], path, skipped, existing[] }.',
22
24
  requires: [],
@@ -15,6 +15,8 @@ var fileNames = require('../../fileNames');
15
15
 
16
16
  module.exports = {
17
17
  name: 'email-download-email',
18
+ // What it hands on, with example values: it writes, so it is never previewed.
19
+ example: { count: 1, saved: [{ filename: 'Invoice 450103.eml', path: 'mail/Invoice 450103.eml', size: 48213, contentType: 'message/rfc822' }], path: 'mail/Invoice 450103.eml', existing: [], subject: 'Invoice 450103', messageId: '4182' },
18
20
  description: 'Save an entire message to disk as a .eml (RFC-822) file, streamed. ' +
19
21
  'Returns { count, saved[], path } like email-download-attachments.',
20
22
  requires: [],
@@ -14,6 +14,8 @@ var emailConfig = require('../../drivers/email/config');
14
14
 
15
15
  module.exports = {
16
16
  name: 'email-move',
17
+ // What it hands on, with example values: it writes, so it is never previewed.
18
+ example: { moved: true, messageId: '4182', fromFolder: 'INBOX', toFolder: 'Processed' },
17
19
  description: 'Move a message to another mailbox folder. The message id is folder-scoped, ' +
18
20
  'so input.folder must name the folder the message is currently in.',
19
21
  requires: [],
@@ -59,6 +59,14 @@ module.exports = {
59
59
  description: 'true → spool messages to an NDJSON file (output.filePath). false → inline output.messages.' }
60
60
  ],
61
61
 
62
+ // The first messages, with the mailbox opened READ-ONLY (IMAP EXAMINE), so
63
+ // a preview never marks anything read — whatever a full download does.
64
+ preview: async function(ctx) {
65
+ var input = Object.assign({}, ctx.input, { limit: require('../../runner').PREVIEW_ROWS, readOnly: true, streaming_mode: false });
66
+ var out = await module.exports.execute({ input: input, system: ctx.system });
67
+ return { output: out, rows: out.messages };
68
+ },
69
+
62
70
  execute: async function(ctx) {
63
71
  var input = ctx.input || {};
64
72
  var system = ctx.system || {};
@@ -66,6 +66,8 @@ function validate(spec) {
66
66
 
67
67
  module.exports = {
68
68
  name: 'export-pages',
69
+ // What it hands on, with example values: it writes, so it is never previewed.
70
+ example: { filePath: 'exports/board.pdf', fileName: 'board.pdf', contentType: 'application/pdf', bytes: 220514, pages: 3 },
69
71
  description: 'Lay out pages of placed blocks (text, tables, images, shapes) as a PDF — a dashboard as it looked.',
70
72
  requires: [],
71
73
  LIMITS: LIMITS,
@@ -57,6 +57,8 @@ function columnsOf(rows) {
57
57
 
58
58
  module.exports = {
59
59
  name: 'export-table',
60
+ // What it hands on, with example values: it writes, so it is never previewed.
61
+ example: { filePath: 'exports/sales.xlsx', fileName: 'sales.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: 18432, rows: 1420, truncated: false, skipped: false },
60
62
  description: 'Write rows to a CSV, Excel (.xlsx) or PDF file. Excel cells stay numbers with their number format; ' +
61
63
  'a PDF shows each cell as it was displayed.',
62
64
  requires: [],
@@ -11,6 +11,8 @@ var dataDir = require('../../dataDir');
11
11
 
12
12
  module.exports = {
13
13
  name: 'file-delete',
14
+ // What it hands on, with example values: it writes, so it is never previewed.
15
+ example: { path: 'bank/inbox/sept.csv', deleted: true, skipped: false },
14
16
  description: 'Delete one file, permanently (file-move to a "processed" folder is the reversible one). Returns { path, deleted, skipped }.',
15
17
  requires: [],
16
18
  inputSchema: [
@@ -36,6 +36,12 @@ module.exports = {
36
36
  { name: 'count', type: 'number', description: 'How many, so the next step can branch on none.' }
37
37
  ],
38
38
 
39
+ // Listing only reads the folder; the preview is the run, its first files.
40
+ preview: async function(ctx) {
41
+ var out = await module.exports.execute(ctx);
42
+ return { output: { files: out.files.slice(0, require('../../runner').PREVIEW_ROWS), count: out.count }, rows: out.files.slice(0, require('../../runner').PREVIEW_ROWS) };
43
+ },
44
+
39
45
  execute: async function(ctx) {
40
46
  var input = ctx.input || {};
41
47
  var dir = dataDir.resolve(input.folder, 'The folder');
@@ -12,6 +12,8 @@ var fileNames = require('../../fileNames');
12
12
 
13
13
  module.exports = {
14
14
  name: 'file-move',
15
+ // What it hands on, with example values: it writes, so it is never previewed.
16
+ example: { path: 'bank/processed/sept.csv', name: 'sept.csv', moved: true, skipped: false },
15
17
  description: 'Move or copy a file into a folder, or to a new name. Returns { path, name, moved, skipped }.',
16
18
  requires: [],
17
19
  inputSchema: [
@@ -38,6 +38,12 @@ module.exports = {
38
38
  { name: 'truncated', type: 'boolean' }
39
39
  ],
40
40
 
41
+ // The file's first rows: reading never changes it.
42
+ preview: async function(ctx) {
43
+ var out = await module.exports.execute({ input: Object.assign({}, ctx.input, { maxRows: require('../../runner').PREVIEW_ROWS }), system: ctx.system });
44
+ return { output: out, rows: out.rows };
45
+ },
46
+
41
47
  execute: async function(ctx) {
42
48
  var input = ctx.input || {};
43
49
  var file = dataDir.resolve(input.path, 'The file');
@@ -48,6 +48,8 @@ var emailConfig = require('../drivers/email/config');
48
48
 
49
49
  module.exports = {
50
50
  name: 'email-send',
51
+ // What it hands on, with example values: it writes, so it is never previewed.
52
+ example: { sent: true, messageId: '<4182@mail.acme.com>', accepted: ['asha@acme.com'], rejected: [], response: '250 OK' },
51
53
  description: 'Send an email over SMTP. Attachments accept { filename, path } descriptors, ' +
52
54
  'so email-download-attachments output can be forwarded directly.',
53
55
  requires: [], // nodemailer checked at runtime
package/lib/dataDir.js CHANGED
@@ -1,135 +1,6 @@
1
- // WHERE A STEP'S FILES ARE — one data root, one folder per tenant.
2
- //
3
- // XEPLR_DATA_DIR the root of all data, absolute, required (requiredEnv)
4
- //
5
- // Every file a step reads or writes is inside the folder of the tenant the
6
- // work runs as, and nowhere else:
7
- //
8
- // <XEPLR_DATA_DIR>/<applicationId>/<mtId1>/<mtId2>/…
9
- //
10
- // The application comes from @xeplr/db's registerApplication (a company id is
11
- // minted per app, so company 42 of BI and company 42 of ERP are two tenants);
12
- // the tenant from the context every query already runs in — a request after
13
- // the membership gate, a job or a workflow run (runWithMt). Never from a
14
- // step's input: a step names `bank/inbox/sept.csv`, and cannot name anything
15
- // outside its own folder.
16
- //
17
- // a name bank/inbox/sept.csv → inside the folder
18
- // a full path accepted only when it is inside the folder already (what
19
- // an earlier step returned: file-list's `path`)
20
- // '*' at a level `_all` — a job for every company has a folder of its own,
21
- // and no company's step can reach it
22
- // no tenant refused, as a query with no tenant finds nothing; never
23
- // the root
24
- //
25
- // `..` and symlinks are resolved before the check, so neither steps out.
26
- // NOT DEFAULTED: "Where data lives is never defaulted." A missing or relative
27
- // XEPLR_DATA_DIR is refused by name, and so is one that does not exist — a
28
- // wrong root that boots reads as empty data, not as an error.
29
-
30
- var fs = require('fs');
31
- var path = require('path');
32
-
33
- var VAR = 'XEPLR_DATA_DIR';
34
- var requiredEnv = [VAR];
35
- var ALL = '_all';
36
- var SCRATCH = 'tmp';
37
-
38
- function refused(message, code) {
39
- var err = new Error(message);
40
- err.code = code || 'PATH_NOT_ALLOWED';
41
- err.status = 400;
42
- return err;
43
- }
44
-
45
- function db() { return require('@xeplr/db'); }
46
-
47
- /** The data root, absolute and real. */
48
- function root() {
49
- var dir = process.env[VAR];
50
- if (!dir || !String(dir).trim()) {
51
- throw refused(VAR + ' is not set: it is the folder all data lives under (absolute), e.g. /srv/xeplr-data.', 'DATA_DIR_UNSET');
52
- }
53
- if (!path.isAbsolute(dir)) throw refused(VAR + ' must be an absolute folder, and "' + dir + '" is not one.', 'DATA_DIR_UNSET');
54
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
55
- throw refused(VAR + ' is "' + dir + '", which is not a folder that exists. Create it, or point ' + VAR + ' at the one that holds the data.', 'DATA_DIR_UNSET');
56
- }
57
- return fs.realpathSync(dir);
58
- }
59
-
60
- // An id becomes a folder name as it is. One that could not be one — or could
61
- // climb out (`..`) — is refused rather than cleaned up into someone else's.
62
- function segment(value, what) {
63
- if (value === '*') return ALL;
64
- var s = value === null || value === undefined ? '' : String(value);
65
- if (!/^[A-Za-z0-9_.-]{1,100}$/.test(s) || s === '.' || s === '..') {
66
- throw refused('The ' + what + ' "' + s + '" cannot name a folder.', 'NO_TENANT');
67
- }
68
- return s;
69
- }
70
-
71
- /**
72
- * The folder the current work may use: <root>/<app>/<mtId1>/…, made if it is
73
- * not there yet. An app with no tenancy registered is one tenant: <root>/<app>.
74
- */
75
- function tenantFolder() {
76
- var x = db();
77
- var app = x.getApplicationId();
78
- if (!app) throw refused('No application is registered (registerApplication), so there is no folder for its files.', 'NO_TENANT');
79
- var parts = [root(), segment(app, 'application')];
80
- var levels = x.getMtConfig().levels || 0;
81
- var ctx = x.getMtContext() || {};
82
- for (var i = 1; i <= levels; i++) {
83
- var v = ctx['mtId' + i];
84
- if (v === undefined || v === null || v === '') {
85
- throw refused('This work runs as no tenant, so it has no folder: files are only ever inside the folder of the company it runs for.', 'NO_TENANT');
86
- }
87
- parts.push(segment(v, 'tenant id'));
88
- }
89
- var dir = path.join.apply(path, parts);
90
- fs.mkdirSync(dir, { recursive: true });
91
- return fs.realpathSync(dir);
92
- }
93
-
94
- /** The tenant's own temporary folder: spools, downloads, exports nobody named. */
95
- function scratch() {
96
- var dir = path.join(tenantFolder(), SCRATCH);
97
- fs.mkdirSync(dir, { recursive: true });
98
- return dir;
99
- }
100
-
101
- function inside(real, folder) {
102
- return real === folder || real.indexOf(folder + path.sep) === 0;
103
- }
104
-
105
- /**
106
- * A name (or a full path already inside the folder) → the real absolute path,
107
- * inside the tenant's folder; refused otherwise. A path that does not exist
108
- * yet (a move's target, an export) is checked by its nearest existing parent,
109
- * so `../` and symlinks cannot step out.
110
- */
111
- function resolve(p, what) {
112
- if (!p || typeof p !== 'string' || !p.trim()) throw refused((what || 'A path') + ' is required.');
113
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) throw refused((what || 'The path') + ' "' + p + '" is an address, not a file in this company\'s folder.');
114
- var folder = tenantFolder();
115
- var resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(folder, p);
116
- var probe = resolved;
117
- var rest = '';
118
- while (!fs.existsSync(probe) && path.dirname(probe) !== probe) {
119
- rest = path.join(path.basename(probe), rest);
120
- probe = path.dirname(probe);
121
- }
122
- var real = path.join(fs.realpathSync(probe), rest);
123
- if (!inside(real, folder)) {
124
- throw refused((what || 'The path') + ' "' + p + '" is outside this company\'s folder. Name it inside, e.g. bank/inbox/statement.csv.');
125
- }
126
- return real;
127
- }
128
-
129
- /** The name a path has inside the tenant's folder — what a screen shows. */
130
- function nameOf(real) {
131
- var folder = tenantFolder();
132
- return inside(real, folder) ? path.relative(folder, real) : null;
133
- }
134
-
135
- module.exports = { VAR: VAR, requiredEnv: requiredEnv, ALL: ALL, root: root, tenantFolder: tenantFolder, scratch: scratch, resolve: resolve, nameOf: nameOf };
1
+ // WHERE A STEP'S FILES ARE — @xeplr/db's dataDir: one root (XEPLR_DATA_DIR),
2
+ // a folder per tenant (<root>/<app>/<mtId1>/…), the tenant from the context
3
+ // the work runs in, never from input. One copy, in @xeplr/db beside the
4
+ // tenant context, so uploads, imports and factory use the same one. See its
5
+ // README ("Files: a folder per tenant").
6
+ module.exports = require('@xeplr/db').dataDir;
@@ -95,7 +95,7 @@ function buildClientConfig(action, connection) {
95
95
  // through this so the release/logout pair can never be forgotten in one of
96
96
  // them. `logout()` is best-effort in its own catch: a failure there must not
97
97
  // mask the real error from the work, and must not fail an op that succeeded.
98
- async function withMailbox(action, connection, folder, fn) {
98
+ async function withMailbox(action, connection, folder, fn, readOnly) {
99
99
  // Config FIRST, deps second. Validating the connection needs no peer dep, so
100
100
  // a misconfigured connection reports the missing field even on a host that
101
101
  // hasn't installed imapflow — otherwise the dep error masks the real one.
@@ -105,7 +105,9 @@ async function withMailbox(action, connection, folder, fn) {
105
105
 
106
106
  await client.connect();
107
107
  try {
108
- var lock = await client.getMailboxLock(folder || 'INBOX');
108
+ // Read-only (IMAP EXAMINE) for a preview: nothing it fetches can mark a
109
+ // message read, whatever the server does on a full download.
110
+ var lock = await client.getMailboxLock(folder || 'INBOX', readOnly ? { readOnly: true } : undefined);
109
111
  try {
110
112
  return await fn(client);
111
113
  } finally {
@@ -257,7 +259,7 @@ async function read(connection, input) {
257
259
  });
258
260
  }
259
261
  return out;
260
- });
262
+ }, input.readOnly === true);
261
263
  }
262
264
 
263
265
  /** Move one message to another folder. Returns the shape the action wraps. */
package/lib/registry.js CHANGED
@@ -9,6 +9,15 @@
9
9
  // outputSchema : array? — optional; documents what the action returns
10
10
  // requires : string[]? — peer-dep module names checked at register time
11
11
  // execute : function — async ({ input, system }) => output
12
+ // preview : function? — async ({ input, system }) => { output, rows? }:
13
+ // what execute WOULD hand on, read without a
14
+ // side effect — a few rows, never a write, a
15
+ // send, a move or a delete. A designer shows it
16
+ // before anything has run (runner.previewAction).
17
+ // example : object? — for an action that cannot be previewed
18
+ // safely (it writes or sends): its output with
19
+ // example values, so a designer still shows
20
+ // the shape.
12
21
  // event : string? — a SCREEN FUNCTION, not a step: the event it
13
22
  // is written for ('beforeSave', 'decide', …).
14
23
  // Called with that event's own context instead
@@ -61,6 +70,8 @@ function register(nameOrDef, def) {
61
70
  outputSchema: action.outputSchema || null,
62
71
  requires: Array.isArray(action.requires) ? action.requires.slice() : [],
63
72
  event: action.event || null,
73
+ preview: typeof action.preview === 'function' ? action.preview : null,
74
+ example: action.example && typeof action.example === 'object' ? action.example : null,
64
75
  execute: action.execute
65
76
  });
66
77
  }
@@ -78,7 +89,11 @@ function list() {
78
89
  inputSchema: def.inputSchema,
79
90
  outputSchema: def.outputSchema,
80
91
  requires: def.requires,
81
- event: def.event
92
+ event: def.event,
93
+ // Whether a designer can ask for a preview — the function itself never
94
+ // leaves the process.
95
+ preview: Boolean(def.preview),
96
+ example: def.example
82
97
  });
83
98
  });
84
99
  return out;
package/lib/runner.js CHANGED
@@ -43,6 +43,40 @@ async function runAction(first, second, third) {
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * WHAT A STEP WOULD HAND ON, without running it. Calls the action's `preview`
48
+ * — never `execute` — with the input checked exactly as a run checks it.
49
+ *
50
+ * Returns { status, output, rows, error, durationMs }:
51
+ * status 'success' output is what execute would return (its shape and
52
+ * sample values); rows, when the action reads rows, the
53
+ * first few of them.
54
+ * status 'example' the action cannot be previewed safely; output is its
55
+ * declared example (or null).
56
+ * status 'failed' the input is incomplete or the read failed — error says.
57
+ * Never throws.
58
+ */
59
+ async function previewAction(first, second, third) {
60
+ var start = Date.now();
61
+ var opts = normalizeArgs(first, second, third);
62
+ var def = opts.action || get(opts.name);
63
+ if (!def) return Object.assign(finish(start, 'failed', null, new ActionNotRegisteredError(opts.name)), { rows: null });
64
+ if (typeof def.preview !== 'function') {
65
+ return Object.assign(finish(start, 'example', def.example || null, null), { rows: null });
66
+ }
67
+ try {
68
+ var input = applySchema(def.inputSchema, coerceScalars(def.inputSchema, opts.input || {}), 'input');
69
+ var seen = await def.preview({ input: input, system: opts.system || {}, metaStore: opts.metaStore });
70
+ seen = seen || {};
71
+ return Object.assign(finish(start, 'success', seen.output === undefined ? null : seen.output, null), { rows: seen.rows || null });
72
+ } catch (err) {
73
+ return Object.assign(finish(start, 'failed', null, err), { rows: null });
74
+ }
75
+ }
76
+
77
+ /** How many rows a preview reads: enough to see, never the data. */
78
+ var PREVIEW_ROWS = 5;
79
+
46
80
  /**
47
81
  * ONE VALUE WHERE A LIST WAS DECLARED MEANS A LIST OF ONE.
48
82
  *
@@ -125,4 +159,4 @@ function withTimeout(promise, ms) {
125
159
  });
126
160
  }
127
161
 
128
- module.exports = { runAction: runAction };
162
+ module.exports = { runAction: runAction, previewAction: previewAction, PREVIEW_ROWS: PREVIEW_ROWS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
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
  "scripts": {
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@duckdb/node-api": "1.5.5-r.3",
43
- "@xeplr/db": "^1.0.8",
43
+ "@xeplr/db": "^1.1.0",
44
44
  "@xeplr/email": "^1.0.0",
45
45
  "@xeplr/utils": "^1.0.0",
46
46
  "csv-parse": "^7.0.2",
@@ -121,7 +121,7 @@
121
121
  },
122
122
  "devDependencies": {
123
123
  "@duckdb/node-api": "^1.5.5-r.3",
124
- "@xeplr/db": "^1.0.8",
124
+ "@xeplr/db": "^1.1.0",
125
125
  "@xeplr/utils": "^1.0.0",
126
126
  "csv-parse": "^7.0.2",
127
127
  "dejavu-fonts-ttf": "^2.37.3",