@xeplr/actions 1.1.10 → 1.1.11

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.
Files changed (2) hide show
  1. package/README.md +302 -0
  2. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,302 @@
1
+ # @xeplr/actions
2
+
3
+ **A registry of named actions with typed inputs, a runner that never throws, and the built-in actions xeplr uses to move data and handle mail.** An action is `{ name, inputSchema, execute }`; `runAction` validates the input against the schema, calls `execute`, and hands back `{ status, output, error, durationMs }`.
4
+
5
+ Use it directly for one-off programmatic runs, or through [`@xeplr/jobs`](https://www.npmjs.com/package/@xeplr/jobs) (scheduled and manual runs) and workflow steps, which look actions up by name. The built-ins cover database reads, writes and movements across Postgres, MySQL, SQL Server and DuckDB, stored procedures, CSV/Excel/JSON file loads, and IMAP/SMTP email. The primitives underneath — the uploader, spool, drivers and parsers — are exported for code that needs them without an action around them.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm i @xeplr/actions @xeplr/db @xeplr/utils
11
+ ```
12
+
13
+ `require('@xeplr/actions')` loads `@xeplr/db` and `@xeplr/utils` at load time (via `attachConfig` and the db/file built-ins), so both must be installed. `@xeplr/db` is not declared in `package.json`; `@xeplr/utils` is declared as an optional peer. Dependency: `@xeplr/schema-handler`.
14
+
15
+ Everything else is an **optional peer**, required only when an action actually uses it:
16
+
17
+ | peer | needed for |
18
+ |---|---|
19
+ | `pg` | `dbType: 'postgres'` |
20
+ | `mysql2` | `dbType: 'mysql'` |
21
+ | `mssql` | `dbType: 'mssql'` |
22
+ | `@duckdb/node-api` (1.5.5-r.3) | `dbType: 'duckdb'` |
23
+ | `csv-parse` | `format: 'csv'` |
24
+ | `unzipper`, `saxes` | `format: 'excel'` (.xlsx) |
25
+ | `imapflow` | every inbound email action |
26
+ | `mailparser` | `email-read` (unless `body: 'none'`), `email-download-attachments` |
27
+ | `nodemailer` | `email-send` with its own connection |
28
+ | `@xeplr/email` | `email-send` with `templateName` |
29
+ | `knex` | the knex meta store |
30
+ | `mongodb`, `ssh2-sftp-client`, `exceljs` | declared, but their drivers/sources are not implemented |
31
+
32
+ A missing peer fails with the `npm install …` line to run, when the action runs (or at `register()` for an action's own `requires`).
33
+
34
+ ## Quick start
35
+
36
+ ```js
37
+ var actions = require('@xeplr/actions')
38
+
39
+ // Your own action
40
+ actions.register({
41
+ name: 'say-hello',
42
+ description: 'Greets someone',
43
+ inputSchema: [{ name: 'who', type: 'string', required: true }],
44
+ execute: async function ({ input, system }) { return { said: 'hello ' + input.who } }
45
+ })
46
+
47
+ var result = await actions.runAction({ name: 'say-hello', input: { who: 'Ada' } })
48
+ // { status: 'success', output: { said: 'hello Ada' }, error: null, durationMs: 1 }
49
+
50
+ // A built-in, without registering it
51
+ var moved = await actions.runAction(actions.builtins.dbMove, {
52
+ sourceDbType: 'postgres', sourceConnection: { host, port, user, password, database: 'app' },
53
+ table: 'orders',
54
+ targetDbType: 'postgres', targetConnection: { host, port, user, password, database: 'warehouse' },
55
+ targetTable: 'orders_copy',
56
+ writeMode: 'upsert', primaryKeys: ['id'],
57
+ window: { column: 'updated_at', from: '2026-08-01', to: '2026-09-01' }
58
+ })
59
+ ```
60
+
61
+ ## API
62
+
63
+ | export | what |
64
+ |---|---|
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
+ | `get(name)` / `has(name)` | Look one up. |
67
+ | `list()` | `[{ name, description, inputSchema, outputSchema, requires }]` — serializable, for an actions endpoint. |
68
+ | `clear()` | Empty the registry. |
69
+ | `runAction({ name, input, system?, timeoutMs? })` | Run a registered action. Never throws. |
70
+ | `runAction(nameOrDef, input, { system, timeoutMs, metaStore }?)` | Same, by name or with an action module directly. |
71
+ | `ActionNotRegisteredError` | Returned (in `error`) for an unknown name. |
72
+ | `ActionMissingDependencyError` | Thrown by `register()` for a missing `requires` module. |
73
+ | `TransientError` | For an action to signal "retry me". Nothing in this package (or `@xeplr/jobs`) acts on it yet. |
74
+ | `builtins` | The built-in action modules — see [Built-in actions](#built-in-actions). |
75
+ | `uploader` | `{ upload, rollback, inferColumns, inferColumnType, makeMetaStore }` — see [Uploader](#uploader). |
76
+ | `inferColumns(rows)` | `[{ name, type }]` from sample rows. |
77
+ | `streaming` | `{ spool, resume, readBatchFile, checkpoint }` — rows to rotating NDJSON batch files with ordered hooks and resumable checkpoints. |
78
+ | `drivers.db` | `{ getDriver(type), checkDriverRequires, SUPPORTED }` — `postgres`, `mysql`, `mssql`, `mongo`, `duckdb`. |
79
+ | `drivers.file` | `{ getSource(type), checkSourceRequires, SUPPORTED }` — `local`, `sharepoint`, `google`, `sftp`. |
80
+ | `formats` | `{ getFormat(type), checkFormatRequires, SUPPORTED }` — `excel`, `csv`, `txt`, `json`. |
81
+ | `attachConfig({ service, applicationId? })` | Connect to the shared `xeplr_configs` database; see [Movement metadata](#movement-metadata-xeplr_configs). |
82
+ | `configRequiredEnv` | `['XCFG_DB_NAME']` — spread into your app's required-env list. |
83
+
84
+ ### An action
85
+
86
+ | field | |
87
+ |---|---|
88
+ | `name` | Required. |
89
+ | `description` | Text for pickers. |
90
+ | `inputSchema` | `[{ name, type, required, default, description, order, ... }]`, validated by `@xeplr/schema-handler` `applySchema`. Built-ins also use `group`, `showWhen`, `system`, `options`, `optionsFrom`, `dependsOn` — hints for a form; validation treats every field the same. |
91
+ | `outputSchema` | Optional documentation of the return value. |
92
+ | `requires` | Module names checked with `require.resolve` at `register()`. |
93
+ | `execute({ input, system, metaStore })` | Returns (or resolves to) the output. |
94
+
95
+ ### `runAction` result
96
+
97
+ ```js
98
+ { status: 'success' | 'failed', output, error: { name, message, stack?, details?, actionName? } | null, durationMs }
99
+ ```
100
+
101
+ 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.
102
+
103
+ ## Built-in actions
104
+
105
+ 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`.
106
+
107
+ | key | action name | does |
108
+ |---|---|---|
109
+ | `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
+ | `dbPush` | `db-push` | Write `rows` or an NDJSON `filePath` into `targetTable` through the uploader. `primaryKeys` → upsert. |
111
+ | `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). |
112
+ | `dbProcedure` | `db-procedure` | Call a stored procedure; returns `{ rows, rowCount, procedure, sql }`. See [db-procedure](#db-procedure). |
113
+ | `dbListTables` | `db-list-tables` | `{ tables }` — base tables in a schema/database. |
114
+ | `dbListViews` | `db-list-views` | `{ views }`. |
115
+ | `dbListProcedures` | `db-list-procedures` | `{ procedures }` (functions excluded). |
116
+ | `dbListColumns` | `db-list-columns` | `{ columns }` — name and data type for one table. |
117
+ | `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`). |
118
+ | `sendEmail` | `email-send` | Send one message. See [Email](#email). |
119
+ | `emailRead` | `email-read` | List messages, newest first, with `unread_only`, `limit` (1–1000, default 50), `since`, `from_equals`, `from_contains`, `subject_contains`, `body: 'full' \| 'text' \| 'none'`, optional NDJSON spooling. |
120
+ | `emailMove` | `email-move` | Move a message (`messageId`, `folder`) to `toFolder`. |
121
+ | `emailDelete` | `email-delete` | Flag `\Deleted` **and expunge** — permanent, not a move to Trash. |
122
+ | `emailDownloadEmail` | `email-download-email` | Stream the raw message to an `.eml` file. `{ count, saved[], path }`. |
123
+ | `emailDownloadAttachments` | `email-download-attachments` | Save attachments, optionally filtered by `extension` / `filenameContains`. `{ count, saved[], path, skipped }`. |
124
+ | `httpRequest`, `spawnProgram`, `dbQuery`, `fileMove` | — | **Placeholders.** Empty modules with no `name` or `execute`; `register()` rejects them. |
125
+
126
+ ### Connections
127
+
128
+ The db actions `db-fetch`, `db-push` and `db-procedure` accept a connection two ways:
129
+
130
+ - `connectionInfoId` (+ `dbInfoId`) — ids of a saved connection. The action does not resolve these; a host does, before `runAction` (e.g. `@xeplr/jobs` `resolveConnection`, which turns `connectionInfoId` into `connection` and `dbType`).
131
+ - `connection: { host, port, user, password, database }` and `dbType` — literal, for scripts, tests and callers with no host.
132
+
133
+ `db-move` takes literal `sourceConnection` / `targetConnection`; `file-upload` takes `dbConnection`. A DuckDB connection is `{ file, access?: 'ro' | 'rw', openTimeoutMs?: 60000, maxConnections?: 4 }`.
134
+
135
+ ### db-move
136
+
137
+ | input | meaning |
138
+ |---|---|
139
+ | `mode` | `table` (default) → `SELECT … FROM table`; `query` → `SELECT … FROM (sql) AS __src`; `procedure` → `sql` is the procedure name. |
140
+ | `columns` | `[{ from, to }]`. For table/query this becomes `SELECT "from" AS "to"`; for a procedure rows are renamed in JS and **unmapped columns are dropped**. Omitted = all columns. |
141
+ | `writeMode` | `append` (default), `replace` (TRUNCATE the target first if it exists, DELETE if TRUNCATE fails), `upsert` (**requires** `primaryKeys`). |
142
+ | `window` | `{ column, from, to }` → `column >= from AND column < to` (a null edge is left out). `{ columns: [{ column, from }] }` → OR of `column >= from` (null `from`s left out). For a procedure: `{ from, to, fromParam, toParam }`, passed as parameters. A window with none of `column`, `columns`, `fromParam`, `toParam` is treated as no window. |
143
+ | `where` | Raw SQL condition, ANDed with the window in parentheses, run on the source. Not parameterised. Ignored for `mode: 'procedure'`. |
144
+ | `movementId` | Correlation key. Default `system.occurrenceId`, else `mv_<id>`. |
145
+ | `batchSize`, `concurrency` | Default 5000 and 4. |
146
+
147
+ For `mode: 'table'` the source's declared column types (`getTableSchema`) are passed to the uploader instead of inferring from values. Returns the uploader result plus `{ movementId, writeMode, truncated, window, sourceSql }`. If `system.log(message, meta)` is supplied it receives one line per step (source, target, columns, write mode, window, SQL, each failed batch with a sample row's primary key, and a summary) — identifiers and counts only, never parameter values or row contents. `system.onProgress` is forwarded to the uploader.
148
+
149
+ ### db-procedure
150
+
151
+ | dbType | statement |
152
+ |---|---|
153
+ | `mssql` | `EXEC name @param = …` |
154
+ | `mysql` | `CALL name(?, ?)` — positional; names are ignored (and logged as such) |
155
+ | `postgres` | `SELECT * FROM fn(param => …)` when `returnsRows` (default `true`), `CALL proc(param => …)` when `false` |
156
+
157
+ `params` is `[{ name, value }]` or bare values; values are always bound. `''` becomes `null`, integer- and decimal-looking strings become numbers, and an object value is an error naming the parameter. A schema-qualified name (`dbo.usp_x`) is quoted per part. `window: { from, to, fromParam, toParam }` appends the two bounds as parameters after `params` (null edges passed as `NULL`); a window without `fromParam`/`toParam` is refused. `maxRows` fails the run when more rows come back. If `system.log` is supplied it gets the statement, parameter count and window bounds — not the other parameter values.
158
+
159
+ ### Email
160
+
161
+ `useCustomConnection` (default `false`) decides where the mailbox comes from:
162
+
163
+ | | inbound (`email-read`, `-move`, `-delete`, `-download-*`) | outbound (`email-send`) |
164
+ |---|---|---|
165
+ | `false` | IMAP from environment variables (below) | `@xeplr/utils` `sendEmail` — the install's configured sender. Accepts only `to`, `subject`, `html`, `cc`, `attachments`; `text`, `bcc`, `from` or `replyTo` are **refused**, and `html` is required. |
166
+ | `true` | `connection: { host, port, secure, user, password, tls? }` | `connection: { host, port, secure, user, password, from, replyTo, envelopeFrom, tls? }` via `nodemailer`. Returns `{ sent, messageId, accepted, rejected, response }`. A fresh transport per send. |
167
+
168
+ `email-send`: `to` / `cc` / `bcc` accept a bare string. `templateName` + `templateVars` render a stored template via `@xeplr/email` (the host must have called its `initTemplates()`), replacing any subject, html and text on the step. Attachments are nodemailer descriptors, so `saved[]` from `email-download-attachments` can be passed straight in.
169
+
170
+ Message ids are IMAP UIDs **scoped to `folder`**, and a move changes the id — download and delete before moving.
171
+
172
+ ## Uploader
173
+
174
+ `uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
175
+
176
+ ```js
177
+ await actions.uploader.upload({
178
+ source, // async iterable of row objects
179
+ driver, // actions.drivers.db.getDriver('postgres')
180
+ connection, // config object or an open pool
181
+ targetTable, movementId,
182
+ queue, // a SqlQueue from @xeplr/utils/lib/queue
183
+ primaryKeys, // optional → upsert
184
+ columns, // optional [{ name, type }] — declared; skips inference
185
+ batchSize: 5000, firstBatchScanRows: 1000,
186
+ metaStore, mtId1, mtId2, mtId3, mtId4, details,
187
+ errorTable, batchDir, keepBatchFiles, onProgress
188
+ })
189
+ // → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, durationMs }
190
+ ```
191
+
192
+ Rows are spooled to NDJSON files on disk between source and database. From the first batch it creates, if missing: the target table, `<targetTable>_import_errors` (`movement_id`, `row_num`, `error_description`, `underlying_sql`, `raw_row`, `recorded_at`), a `__xeplr_movement_id__` column, and a unique index on `primaryKeys`. Server dialects also add `__xeplr_id__`; DuckDB does not.
193
+
194
+ Logical types and what each driver creates:
195
+
196
+ | type | postgres | mysql | mssql | duckdb |
197
+ |---|---|---|---|---|
198
+ | string | TEXT | LONGTEXT | NVARCHAR(MAX) | VARCHAR |
199
+ | number | NUMERIC | DECIMAL(38,10) | DECIMAL(38,10) | DOUBLE |
200
+ | boolean | BOOLEAN | TINYINT(1) | BIT | BOOLEAN |
201
+ | date | DATE | DATE | DATE | DATE |
202
+ | datetime | TIMESTAMPTZ | DATETIME(6) | DATETIME2 | TIMESTAMPTZ |
203
+ | object / array | JSONB | JSON | NVARCHAR(MAX) | JSON |
204
+
205
+ Inference (`inferColumns`): any array → `array`; any object → `object`; all `Date` or ISO-date strings → `datetime`; all booleans → `boolean`; all numbers → `number`; anything else, or all null → `string`.
206
+
207
+ `uploader.rollback({ movementId, driver, connection, targetTable, metaStore?, queue? })` aborts the movement's queued work, deletes its rows from the target and error tables by `__xeplr_movement_id__` / `movement_id`, and records `rolled-back`.
208
+
209
+ ### Formats and file sources
210
+
211
+ | format | reads | notes |
212
+ |---|---|---|
213
+ | `csv` | stream | `formatConfig`: `columns` (default first row), `delimiter` (`,`), `cast` (true), `skipEmptyLines`, `trim`. |
214
+ | `excel` | file path | `.xlsx` via `unzipper` + `saxes`; `sheet`, `startRow`. Date-styled cells arrive as `Date`. |
215
+ | `json` | stream | A JSON array or NDJSON; buffers the whole file. |
216
+ | `txt` | — | Placeholder, not implemented. |
217
+
218
+ File sources: only `local` is implemented (`path`). `sftp`, `sharepoint` and `google` are placeholders.
219
+
220
+ ## Movement metadata (`xeplr_configs`)
221
+
222
+ Pass a `metaStore` and each upload records a row in `import_meta`: start, running counters, end status, columns, primary keys, tenant ids and `details`.
223
+
224
+ ```js
225
+ var xcfg = actions.attachConfig({ service: 'xeplr-workflow', applicationId: 'xeplr-bi' })
226
+ await xcfg.ready()
227
+ await actions.runAction(actions.builtins.dbPush, input, { metaStore: xcfg.metaStore })
228
+ // xcfg.ImportMeta — BaseModel bound to xeplr_configs; xcfg.db — its knex; xcfg.SERVICE_NAME; xcfg.APPLICATION_ID
229
+ ```
230
+
231
+ `ready()` connects through `@xeplr/db` `bootstrapConfigDb` and checks every table there has an `applicationId` column (`assertApplicationIdColumns`). `applicationId` defaults to the application registered with `@xeplr/db`, else `service`, and is read at `ready()`, not at `attachConfig()`.
232
+
233
+ `uploader.makeMetaStore(knex, { table: 'import_meta', service, applicationId })` builds the same store on your own knex. Without a `metaStore` nothing is recorded.
234
+
235
+ ## Environment variables
236
+
237
+ | name | required? | meaning |
238
+ |---|---|---|
239
+ | `XEPLR_ACTIONS_TMP_DIR` | no | Where `db-fetch` and `email-read` spool NDJSON, email downloads go (unless `outDir`), and the uploader spools batches. Default `<os.tmpdir()>/xeplr-actions`. Files are not cleaned up by `db-fetch` or `email-read`. |
240
+ | `XCFG_DB_NAME` | for `attachConfig` | The `xeplr_configs` database name. In `configRequiredEnv`. |
241
+ | `XCFG_DB_CONNECTION_INFO_ENCRYPTED` | no | Encrypted server login for `attachConfig`; overrides `XEPLR_DB_CONNECTION`. |
242
+ | `XEPLR_DB_CONNECTION` | for `attachConfig`, unless the above is set | Shared encrypted server login. |
243
+ | `ENCRYPTION_KEY` | for `attachConfig` | Decrypts the connection string. |
244
+ | `IMAP_HOST` / `IMAP_USER` / `IMAP_PASS` | for inbound email without `useCustomConnection` | Mailbox login. Each falls back to `SMTP_HOST` / `SMTP_USER` / `SMTP_PASS`. Missing → error with code `EMAIL_SYSTEM_NOT_CONFIGURED`. |
245
+ | `IMAP_PORT` | no | Default 993. |
246
+ | `IMAP_SECURE` | no | Default `true` (`1/true/yes/on`, `0/false/no/off`). |
247
+ | `IMAP_CONNECTION_TIMEOUT`, `IMAP_SOCKET_TIMEOUT` | no | Milliseconds, passed to `imapflow`. |
248
+ | `IMAP_TLS_REJECT_UNAUTHORIZED`, `IMAP_TLS_SERVERNAME` | no | TLS options. |
249
+
250
+ Outbound mail without `useCustomConnection` reads no variables here — it uses whatever `@xeplr/utils` / `@xeplr/email` were configured with.
251
+
252
+ ## Routes, migrations, CLI
253
+
254
+ None published. The package has no HTTP routes (a host serves `list()`, e.g. `@xeplr/jobs` `GET /actions`), no migrations (the `import_meta` table is created by `@xeplr/db`'s config migrations) and no `bin`.
255
+
256
+ The repository has `scripts/upload-file.js` (not in the npm package) — a CLI that loads a CSV or Excel file into Postgres with `file-upload`; its header documents the flags (`--file`, `--table`, `--conn-name`, `--conn`, `--host`/`--port`/`--db`/`--user`/`--password`, `--encrypt-conn`).
257
+
258
+ ## Rules the code enforces, and why
259
+
260
+ | rule | why |
261
+ |---|---|
262
+ | `runAction` never throws; every failure is a `failed` result. | Schedulers and workflow engines record the outcome instead of crashing on it. |
263
+ | Input is validated by `applySchema`, which **drops fields the schema does not declare**. That is why `connection` and `dbType` stay declared (as `system: true`) even when a host fills them. | Otherwise a resolved connection would be stripped on its way to `execute`. |
264
+ | A scalar given for an `array` field becomes a one-element array. Objects, `null`, `undefined` and `''` are left alone; JSON-looking strings are not parsed. | A bound reference such as `{params.email}` interpolates to a string; without this every array input was unbindable. |
265
+ | Peer dependencies are lazy: checked when an action runs with a given `dbType`, `format`, `sourceType` or email path. | A deployment installs only the drivers it uses. |
266
+ | Saved connection ids are for forms; literal credentials are for scripts. | Job inputs reach the browser and are copied into every run record, so a password there is everywhere. |
267
+ | `db-fetch` streams to disk by default; `streaming_mode: false` only for small reads. | Bounded memory at both ends for cross-engine replication. |
268
+ | **Half-open windows** (`from <= x < to`), no +1 second. | Consecutive windows meet exactly; closed windows move the boundary row twice. |
269
+ | `db-move` `upsert` without `primaryKeys` is refused, not downgraded to append. | A silent append is a duplicate every run, found a week later. |
270
+ | `replace` truncates before reading; on failure the target stays empty. | Accepted trade against keeping a second copy of the data. |
271
+ | A procedure call with a window but no `fromParam`/`toParam` is refused (`db-procedure`, and the shared builder `db-move` uses). | A procedure cannot be filtered from outside; dropping the window would reprocess its whole history every run while reporting success. |
272
+ | `db-move` uses the source table's declared types for `mode: 'table'`. | Inference is lossy: Postgres `numeric` arrives as a string and would create a TEXT column; an all-null sample becomes text; `'007'` loses its zeros. |
273
+ | Target types win over source types; a source column missing from an existing target **fails** the upload. | The existing table is authoritative; the fix is an explicit ALTER. |
274
+ | Batch size is capped by the driver's parameter limit (Postgres 30,000, MySQL 60,000, SQL Server 2,000). | Protocol limits: 65,535 params in Postgres; 2,100 params and 1,000 rows per statement in SQL Server. |
275
+ | **Rollback is refused for a movement that used `primaryKeys`.** | Upsert stamps the movement id on rows it only updated, so deleting by it would remove rows that already existed. |
276
+ | Movement logs carry identifiers, counts, SQL and the error text — never parameter values or row contents; a failed batch shows one row's primary key, not its data. | Logs are copied into tickets and chats. |
277
+ | DuckDB opens **read-only unless the call says `access: 'rw'`**; no environment variable can grant write. A file already open read-only in the process refuses a write. Opening waits on the lock (`openTimeoutMs`, default 60 s). | One read-write process locks every other process out, even readers. Write access belongs at the call site that writes. |
278
+ | `meta-store-knex` requires `service` and `applicationId`, and scopes every statement by `applicationId`. | `import_meta` is shared by every app; unattributed rows, or a rollback decided from another app's row, are worse than refusing. |
279
+ | Mail filenames are sanitised (`[^\w.-]` → `_`, all-dot names → `attachment`, max 180 chars) and prefixed with a random id. | Attachment names come from the sender and could escape the output directory; common names collide. |
280
+ | The system mail sender refuses `text`, `bcc`, `from`, `replyTo` rather than dropping them. | A silently lost bcc is a compliance problem found a year later. |
281
+ | `email-send` returns both `accepted` and `rejected`. | SMTP accepts per recipient; a "success" can drop half of them. |
282
+
283
+ ## Tests
284
+
285
+ There is no `npm test` script. The tests use `node:test`; run a file directly:
286
+
287
+ ```sh
288
+ node --test test/uploader/reconcile.test.js
289
+ ```
290
+
291
+ | needs nothing | needs a database |
292
+ |---|---|
293
+ | `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
294
+ | `test/drivers/duckdb.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
295
+ | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
296
+ | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
297
+
298
+ `test/db-move.live.js` runs against a developer's own `.env` path and is not a portable test. CI (`.github/workflows/ci.yml`) skips tests when there is no `test` script.
299
+
300
+ ## License
301
+
302
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.10",
3
+ "version": "1.1.11",
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": [