@xeplr/actions 1.1.16 → 1.1.18

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). |
@@ -113,8 +114,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
113
114
  | `dbListTables` | `db-list-tables` | `{ tables }` — base tables in a schema/database. |
114
115
  | `dbListViews` | `db-list-views` | `{ views }`. |
115
116
  | `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`). |
117
+ | `dbListColumns` | `db-list-columns` | `{ columns }` — `name`, `dataType`, `udtName` and `maxLength` (characters; `-1` is SQL Server `(max)`, `null` when it has none) for one table. |
118
+ | `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
118
119
  | `sendEmail` | `email-send` | Send one message. See [Email](#email). |
119
120
  | `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
121
  | `emailMove` | `email-move` | Move a message (`messageId`, `folder`) to `toFolder`. |
@@ -130,7 +131,7 @@ The db actions `db-fetch`, `db-push` and `db-procedure` accept a connection two
130
131
  - `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
132
  - `connection: { host, port, user, password, database }` and `dbType` — literal, for scripts, tests and callers with no host.
132
133
 
133
- `db-move` takes literal `sourceConnection` / `targetConnection`; `file-upload` takes `dbConnection`. A DuckDB connection is `{ file, access?: 'ro' | 'rw', openTimeoutMs?: 60000, maxConnections?: 4 }`.
134
+ `db-move` takes literal `sourceConnection` / `targetConnection`; `file-upload` takes `dbConnection`. A DuckDB connection is `{ file, access?: 'ro' | 'rw', openTimeoutMs?: 60000, maxConnections?: 4, keepIndexes?: false }` (see [A written DuckDB file keeps no index](#a-written-duckdb-file-keeps-no-index)).
134
135
 
135
136
  ### db-move
136
137
 
@@ -141,7 +142,8 @@ The db actions `db-fetch`, `db-push` and `db-procedure` accept a connection two
141
142
  | `writeMode` | `append` (default), `replace` (TRUNCATE the target first if it exists, DELETE if TRUNCATE fails), `upsert` (**requires** `primaryKeys`). |
142
143
  | `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
144
  | `where` | Raw SQL condition, ANDed with the window in parentheses, run on the source. Not parameterised. Ignored for `mode: 'procedure'`. |
144
- | `staged` | `true`: write into `<targetTable>__xeplr_staging` and move it into the target in one transaction at the end (see [Staged loads](#staged-loads)). DuckDB targets only. Default `false`. |
145
+ | `replaceFrom` | `{ column }` with `staged`: the final move deletes target rows from the earliest staged value of `column` on, before inserting the staged ones — see [Staged loads](#staged-loads). |
146
+ | `staged` | `true`: write into `<targetTable>__xeplr_staging` and move it into the target in one transaction at the end (see [Staged loads](#staged-loads)). DuckDB targets only. Default `false`, except that **every DuckDB upsert is staged** whatever this says. |
145
147
  | `movementId` | Correlation key. Default `system.occurrenceId`, else `mv_<id>`. |
146
148
  | `batchSize`, `concurrency` | Default 5000 and 4. |
147
149
 
@@ -170,6 +172,54 @@ For `mode: 'table'` the source's declared column types (`getTableSchema`) are pa
170
172
 
171
173
  Message ids are IMAP UIDs **scoped to `folder`**, and a move changes the id — download and delete before moving.
172
174
 
175
+ ## Data operation
176
+
177
+ The step between steps. A fetch returns everything a table holds; an email
178
+ wants four columns, the active rows, sorted, and sometimes rendered. Without
179
+ this, each of those is a module somebody writes and maintains — and the third
180
+ is written differently from the first two.
181
+
182
+ ```js
183
+ await runAction({ name: 'data-operation', input: {
184
+ from: rows, // a list; one object is one row
185
+ where: 'status = "active" and [due] <= today()',
186
+ columns: ['name', { name: 'late', label: 'Days late', formula: 'datediff([due], today())' },
187
+ { name: 'id', hidden: true }],
188
+ sort: '-late',
189
+ format: true, title: 'Overdue'
190
+ } })
191
+ // → { rows, count, html, text }
192
+ ```
193
+
194
+ **Rows and a rendering, both.** `rows` and `count` are always there; `html`
195
+ and `text` only when `format` is on, because building a table for ten
196
+ thousand rows nobody renders is work done and stored on every run. Neither is
197
+ privileged — the next step binds whichever it wants.
198
+
199
+ **`count` is the point of the step as often as `rows` is**: it is what lets
200
+ the step after it branch on *nothing to report* instead of sending an empty
201
+ email.
202
+
203
+ **Formulas are `@xeplr/expression-handler`** — the engine running BI's report
204
+ formulas and workflow's conditions, with the same functions. `where` and a
205
+ computed column are the same language.
206
+
207
+ **What `today()` means comes from the run's context**, not from the action:
208
+ `context.formula` carries `today` and `fiscalYearStart`, so a workspace that
209
+ reckons days elsewhere and a run replayed from history agree on what
210
+ `yesterday()` filtered. The action never calls `new Date()`.
211
+
212
+ **Only visible columns are rendered.** A column kept for the next step but
213
+ marked `hidden` stays in `rows` and never reaches the table — an id the flow
214
+ needs is not a column a person wants printed.
215
+
216
+ **A ragged row does not fail the step.** A column missing from one row reads
217
+ as nothing (`missing: 'null'`), because one bad row out of nine hundred
218
+ should not take the other eight hundred and ninety-nine with it.
219
+
220
+ **No aggregation.** `group by status, count` changes what a row *is*; that is
221
+ a different shape and, when it is wanted, a different step.
222
+
173
223
  ## Uploader
174
224
 
175
225
  `uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
@@ -196,7 +246,7 @@ await actions.uploader.upload({
196
246
 
197
247
  `signal` (an `AbortSignal`, passed to db-move as `system.signal`) stops a running movement: the writes still queued are dropped at once (in-flight ones settle), the read stops at the next batch, and it ends with an error whose `code` is `'STOPPED'` (the runner keeps `error.code`), neither a failure nor a success. What it wrote stays, under its `movementId`, for the caller to roll back or keep.
198
248
 
199
- 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.
249
+ 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` (not on DuckDB: its upserts are staged and keep none). Server dialects also add `__xeplr_id__`; DuckDB does not.
200
250
 
201
251
  Logical types and what each driver creates:
202
252
 
@@ -219,9 +269,22 @@ With `staged: true` a load **never writes into its target while it runs**. Batch
219
269
  - **Crash during the move**: the transaction is discarded on reopen and the target is as it was.
220
270
  - **Did it land?** `uploader.wasCommitted({ driver, connection, movementId })` reads the ledger row, written in the same transaction as the move: a row means it landed, `null` means nothing did. No scan of the target.
221
271
  - **Upsert** replaces target rows whose key is staged (`DELETE … USING` staging, then `INSERT`). Within one load the last row staged for a key wins, as under `ON CONFLICT DO UPDATE`; rows with a NULL key match nothing and are kept. **No unique index**: a staged load drops `<targetTable>_upsert_uniq` and never creates it. DuckDB's DELETE through an index that a killed load left out of step with its rows fails with "Failed to delete all rows from index", and that failure invalidates the whole database.
272
+ - **A range** (`replaceFrom: { column }`, staged append only, no `primaryKeys`) replaces the target's rows from **the earliest staged value** of `column` on by the staged ones, in the same one transaction: *re-load the last N days*, for a table with no key or a date with no time, where reading "past the latest" would duplicate today on every run. The caller reads the source from its cutoff; the boundary is taken from what that read brought, never from a date passed in, because a cutoff computed elsewhere sits a time zone away (a `DATE` read as UTC midnight here, compared at local midnight at the source) and a day deleted but not read back is lost. Nothing staged deletes nothing. `merged.replaced` is the rows deleted.
222
273
  - **Cost** is one statement at the end. Measured with 50 lakh staged rows into 50 crore: append 0.3 s (independent of the target's size), upsert 0.4–7 s depending on how widely the replaced keys are spread.
223
274
 
224
- A driver opts in by implementing `prepareStaging`, `mergeStaged`, `dropStaging` and `committedMovement`; `upload` refuses `staged` for one that does not.
275
+ A driver opts in by implementing `prepareStaging`, `mergeStaged`, `dropStaging` and `committedMovement`; `upload` refuses `staged` for one that does not. A driver that sets `stagesUpserts: true` (DuckDB) has every upsert staged, asked or not.
276
+
277
+ ### A written DuckDB file keeps no index
278
+
279
+ DuckDB 1.5.4–1.5.6 has a bug ([duckdb/duckdb#26106](https://github.com/duckdb/duckdb/issues/26106), open upstream): when a process dies with rows still only in the WAL, the next open replays them into the table and the next checkpoint drops them from every index on it. The first `DELETE` that reaches one fails with *"Failed to delete all rows from index"*, which is **FATAL**: the whole file is unusable until the process restarts. Any index, unique or not. A table with no index is unaffected.
280
+
281
+ So the DuckDB driver, on every **read-write** open of a file:
282
+
283
+ 1. runs `CHECKPOINT` first, which folds a replayed WAL in safely before anything touches it (the upstream workaround);
284
+ 2. **drops every index** in the file, logging which, so one left from before this rule cannot be what a later crash breaks;
285
+ 3. **refuses `CREATE INDEX`** on that pool from then on (`ensureUpsertIndex` included).
286
+
287
+ Upserts need no index: they are staged and match keys in the move. A read-only open changes nothing. `keepIndexes: true` turns 2 and 3 off, only for a file that is never written after its index exists; `:memory:` is exempt, having no WAL to replay. `test/duckdb-index-guard.test.js` kills a real writer process to bring the failure about, proves it still happens on the pinned DuckDB, and proves each step above prevents it.
225
288
 
226
289
  `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`.
227
290
 
@@ -331,7 +394,7 @@ node --test test/uploader/reconcile.test.js
331
394
  | needs nothing | needs a database |
332
395
  |---|---|
333
396
  | `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.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` |
334
- | `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
397
+ | `test/list-columns-length.test.js` (`XEPLR_TEST_PG_URL`, `MYSQL_TEST_URL`, `MSSQL_TEST_URL`; each skipped when unset), `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/duckdb-index-guard.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
335
398
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
336
399
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
337
400
 
@@ -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
+ }
@@ -99,6 +99,12 @@ module.exports = {
99
99
  'the end, so a movement that stops or fails midway never touches the target. ' +
100
100
  'DuckDB targets only.' },
101
101
 
102
+ { name: 'replaceFrom', type: 'object', order: 12.6, group: 'Write options',
103
+ description: 'With staged and append: { column }. The final move first deletes the target ' +
104
+ 'rows where column >= the earliest staged value, then inserts the staged rows, in the ' +
105
+ 'same one transaction — "re-load the last N days" for a table with no key, or a date ' +
106
+ 'with no time. Read the source from the cutoff; nothing staged deletes nothing.' },
107
+
102
108
  { name: 'window', type: 'object', order: 13, group: 'Advanced',
103
109
  description: 'One window: { column, from, to }. Half-open — from <= x < to. ' +
104
110
  'Omitted = no filter. For mode="procedure", also needs { fromParam, toParam }.' },
@@ -315,7 +321,8 @@ module.exports = {
315
321
  onProgress: system.onProgress || null,
316
322
  // An AbortSignal from the caller: stop reading, drop the queued writes.
317
323
  signal: system.signal || null,
318
- staged: Boolean(input.staged)
324
+ staged: Boolean(input.staged),
325
+ replaceFrom: input.replaceFrom || null
319
326
  });
320
327
 
321
328
  // Every count upload() has, not just the headline one — "read" and
@@ -113,7 +113,12 @@ module.exports = {
113
113
  // opaque tenant/app context — see runner.js's ctx.system passthrough.
114
114
  metaStore: ctx.metaStore,
115
115
  mtId1: system.mtId1, mtId2: system.mtId2, mtId3: system.mtId3, mtId4: system.mtId4,
116
- details: system.details
116
+ details: system.details,
117
+ // Progress and the stop switch, as db-move passes them: the uploader
118
+ // supports both, and without them a long import could neither say how
119
+ // far it had got nor be stopped.
120
+ onProgress: system.onProgress || null,
121
+ signal: system.signal || null
117
122
  });
118
123
  } finally {
119
124
  queue.stop();
@@ -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
 
@@ -196,6 +196,9 @@ async function openWithWait(api, key, options, access, timeoutMs) {
196
196
  * config.openTimeoutMs how long to wait out another holder
197
197
  * (default 60s — a load can be slow)
198
198
  * config.maxConnections default 4
199
+ * config.keepIndexes true to allow indexes in this file. Off by
200
+ * default — see "a written file keeps no
201
+ * index" below.
199
202
  *
200
203
  * READ-ONLY UNLESS ASKED. A caller that forgets `access: 'rw'` gets an error
201
204
  * on its first write, in the code doing the writing — not a silent success on
@@ -248,10 +251,78 @@ async function connect(config) {
248
251
  max: Math.max(1, parseInt(config.maxConnections || 4, 10))
249
252
  };
250
253
  entry.pool = { __duck: entry, access: access, file: key };
254
+ // ':memory:' never outlives its process, so it has no WAL to replay and
255
+ // nothing for the guard to protect.
256
+ entry.keepIndexes = Boolean(config.keepIndexes) || key === ':memory:';
257
+ if (access === 'rw' && key !== ':memory:') {
258
+ try {
259
+ await guardOnOpen(entry);
260
+ } catch (err) {
261
+ try { instance.closeSync(); } catch (_) {}
262
+ throw err;
263
+ }
264
+ }
251
265
  INSTANCE_CACHE[key] = entry;
252
266
  return entry.pool;
253
267
  }
254
268
 
269
+ // ── a written file keeps no index ────────────────────────────────────────
270
+ //
271
+ // DuckDB 1.5.4–1.5.6 (duckdb/duckdb#26106, open upstream): after a process
272
+ // dies with rows still only in the WAL, the next open replays them into the
273
+ // table, and the next checkpoint drops them from every ART index on it. The
274
+ // rows are still there; the index no longer knows them. The first DELETE that
275
+ // reaches one fails with "Failed to delete all rows from index" — FATAL, and
276
+ // the whole file is unusable until the process restarts. Unique or not, any
277
+ // index. With no index there is nothing to fall out of step.
278
+ //
279
+ // So on every read-write open of a file:
280
+ // 1. CHECKPOINT first — folds a replayed WAL into the file before anything
281
+ // else touches it, the upstream workaround (proven here: kill, reopen,
282
+ // CHECKPOINT, DELETE succeeds even with an index).
283
+ // 2. DROP every index in it — one left from before this rule, or one that
284
+ // slipped in, cannot be the thing a later crash breaks.
285
+ // and CREATE INDEX is refused on the pool from then on (see query). Upserts do
286
+ // not need one: they are staged and matched in the move (mergeStaged).
287
+ //
288
+ // A reader cannot trip this: a read-only open replays nothing and deletes
289
+ // nothing, and a file a crash left damaged gets its index dropped the first
290
+ // time anything opens it to write.
291
+ async function guardOnOpen(entry) {
292
+ var conn = await entry.instance.connect();
293
+ try {
294
+ await conn.run('CHECKPOINT');
295
+ if (entry.keepIndexes) return;
296
+ var reader = await conn.runAndReadAll(
297
+ "SELECT schema_name, index_name, table_name FROM duckdb_indexes()"
298
+ );
299
+ var found = reader.getRowObjectsJS();
300
+ for (var i = 0; i < found.length; i++) {
301
+ await conn.run('DROP INDEX IF EXISTS ' + quoteIdent(found[i].schema_name) + '.' + quoteIdent(found[i].index_name));
302
+ }
303
+ if (found.length) {
304
+ await conn.run('CHECKPOINT');
305
+ console.warn('[duckdb] ' + entry.key + ': dropped ' + found.length + ' index(es) (' +
306
+ found.map(function(f) { return f.table_name + '.' + f.index_name; }).join(', ') +
307
+ ') — a written file keeps none; see duckdb/duckdb#26106');
308
+ }
309
+ } finally {
310
+ try { conn.closeSync(); } catch (_) {}
311
+ }
312
+ }
313
+
314
+ var CREATE_INDEX = /^\s*CREATE\s+(UNIQUE\s+)?INDEX\b/i;
315
+
316
+ function refuseIndex(entry, sql) {
317
+ if (entry.keepIndexes || !CREATE_INDEX.test(String(sql || ''))) return;
318
+ throw new Error(
319
+ 'duckdb: CREATE INDEX refused on "' + entry.key + '". A file that is written keeps no index: ' +
320
+ 'DuckDB loses rows from an index after a crash (duckdb/duckdb#26106), and the next DELETE ' +
321
+ 'then fails fatally. Upserts are staged and need none. Open with keepIndexes: true only for ' +
322
+ 'a file that is never written after the index exists.'
323
+ );
324
+ }
325
+
255
326
  /** Check out a connection, creating one lazily up to `max`, else queueing. */
256
327
  async function acquire(entry) {
257
328
  if (entry.free.length) return entry.free.pop();
@@ -341,6 +412,7 @@ function plainDeep(v) {
341
412
  */
342
413
  async function query(pool, sql, params) {
343
414
  var entry = entryOf(pool);
415
+ refuseIndex(entry, sql);
344
416
  var conn = await acquire(entry);
345
417
  try {
346
418
  var api = require('@duckdb/node-api');
@@ -528,7 +600,7 @@ async function ensureMovementColumn(pool, tableName) {
528
600
 
529
601
  async function ensureUpsertIndex(pool, tableName, primaryKeys) {
530
602
  var sql = buildUpsertIndexSql(tableName, primaryKeys);
531
- if (sql) await query(pool, sql);
603
+ if (sql) await query(pool, sql); // refused on a guarded file — the uploader stages DuckDB upserts instead
532
604
  }
533
605
 
534
606
  // ── introspection ────────────────────────────────────────────────────────
@@ -800,6 +872,11 @@ async function dropStaging(pool, tableName) {
800
872
  * under ON CONFLICT DO UPDATE; a row with a NULL key matches nothing, and is
801
873
  * kept, as ON CONFLICT kept it.
802
874
  *
875
+ * With replaceFrom { column } it replaces a RANGE: target rows with column >=
876
+ * the earliest staged value are deleted first ("re-load the last N days" —
877
+ * the caller reads the source from its cutoff; the boundary here is what that
878
+ * read actually brought). `replaced` is then the rows deleted.
879
+ *
803
880
  * Returns { inserted, replaced }.
804
881
  */
805
882
  async function mergeStaged(pool, opts) {
@@ -824,6 +901,21 @@ async function mergeStaged(pool, opts) {
824
901
  await conn.run('BEGIN TRANSACTION');
825
902
  try {
826
903
  var replaced = 0;
904
+ // A RANGE REPLACED: the target's rows from the EARLIEST STAGED value
905
+ // on go, and the staged rows take their place — inside this one
906
+ // transaction, so a crash leaves the old rows, never neither.
907
+ //
908
+ // From what was staged, not from a date passed in. The staged values
909
+ // came through the same reads and conversions as the target's own, so
910
+ // this boundary is exact; a cutoff computed elsewhere is a time zone
911
+ // away from it (a DATE read as UTC midnight here, compared at the
912
+ // source's local midnight there) — and a day deleted but never read
913
+ // back is a day lost. Nothing staged: MIN is NULL, nothing is deleted.
914
+ if (opts.replaceFrom) {
915
+ var col = quoteIdent(opts.replaceFrom.column);
916
+ var cut = await conn.run('DELETE FROM ' + target + ' WHERE ' + col + ' >= (SELECT MIN(' + col + ') FROM ' + staging + ')');
917
+ replaced = Number(cut.rowsChanged) || 0;
918
+ }
827
919
  if (pks) {
828
920
  var del = await conn.run('DELETE FROM ' + target + ' AS t USING ' + staging + ' AS s WHERE ' +
829
921
  pks.map(function(k) { return 't.' + quoteIdent(k) + ' = s.' + quoteIdent(k); }).join(' AND '));
@@ -881,6 +973,9 @@ module.exports = {
881
973
 
882
974
  ensureMovementColumn: ensureMovementColumn,
883
975
  ensureUpsertIndex: ensureUpsertIndex,
976
+ // An upsert into DuckDB is always staged: it matches keys in the move, so it
977
+ // needs no index, which a written DuckDB file must not keep.
978
+ stagesUpserts: true,
884
979
 
885
980
  getTableSchema: getTableSchema,
886
981
  listTables: listTables,
@@ -314,14 +314,15 @@ function toMssqlDatetime(v) {
314
314
 
315
315
  async function getTableSchema(pool, tableName, schema) {
316
316
  var r = await query(pool,
317
- 'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType ' +
317
+ 'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
318
318
  'FROM INFORMATION_SCHEMA.COLUMNS ' +
319
319
  'WHERE TABLE_NAME = @p0 AND (@p1 IS NULL OR TABLE_SCHEMA = @p1) ' +
320
320
  'ORDER BY ORDINAL_POSITION',
321
321
  [tableName, schema || null]
322
322
  );
323
323
  return r.rows.map(function(row) {
324
- return { name: row.name, dataType: row.dataType, udtName: row.dataType };
324
+ // maxLength -1 is (max): nvarchar(max), varchar(max), varbinary(max).
325
+ return { name: row.name, dataType: row.dataType, udtName: row.dataType, maxLength: row.maxLength == null ? null : Number(row.maxLength) };
325
326
  });
326
327
  }
327
328
 
@@ -311,14 +311,14 @@ function toMysqlDatetime(v) {
311
311
 
312
312
  async function getTableSchema(pool, tableName, schema) {
313
313
  var r = await query(pool,
314
- 'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName ' +
314
+ 'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
315
315
  'FROM information_schema.COLUMNS ' +
316
316
  'WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? ' +
317
317
  'ORDER BY ORDINAL_POSITION',
318
318
  [schema || null, tableName]
319
319
  );
320
320
  return r.rows.map(function(row) {
321
- return { name: row.name, dataType: row.dataType, udtName: row.udtName };
321
+ return { name: row.name, dataType: row.dataType, udtName: row.udtName, maxLength: row.maxLength == null ? null : Number(row.maxLength) };
322
322
  });
323
323
  }
324
324
 
@@ -290,14 +290,15 @@ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
290
290
  // in ordinal-position order. Empty array if the table doesn't exist.
291
291
  async function getTableSchema(pool, tableName, schema) {
292
292
  var r = await pool.query(
293
- "SELECT column_name, data_type, udt_name " +
293
+ "SELECT column_name, data_type, udt_name, character_maximum_length " +
294
294
  "FROM information_schema.columns " +
295
295
  "WHERE table_schema = $2 AND table_name = $1 " +
296
296
  "ORDER BY ordinal_position",
297
297
  [tableName, schema || 'public']
298
298
  );
299
299
  return r.rows.map(function(row) {
300
- return { name: row.column_name, dataType: row.data_type, udtName: row.udt_name };
300
+ return { name: row.column_name, dataType: row.data_type, udtName: row.udt_name,
301
+ maxLength: row.character_maximum_length == null ? null : Number(row.character_maximum_length) };
301
302
  });
302
303
  }
303
304
 
@@ -28,6 +28,8 @@
28
28
  // errorTable, // defaults to `<targetTable>_import_errors`
29
29
  // batchDir, // where NDJSON files spool to. Defaults to os.tmpdir()/xeplr-actions
30
30
  // keepBatchFiles, // default false — clean up NDJSON files after success
31
+ // replaceFrom, // optional, staged only: { column } — the move deletes target
32
+ // // rows from the earliest staged value on first (see mergeStaged)
31
33
  // staged, // default false. true: batches land in <targetTable>__xeplr_staging
32
34
  // // and move into the target in ONE transaction at the end, so a
33
35
  // // load that stops or dies midway never touched the target.
@@ -247,7 +249,8 @@ async function upload(opts) {
247
249
  targetTable: opts.targetTable,
248
250
  columns: columns,
249
251
  primaryKeys: opts.primaryKeys,
250
- movementId: opts.movementId
252
+ movementId: opts.movementId,
253
+ replaceFrom: opts.replaceFrom
251
254
  });
252
255
  }
253
256
  await opts.metaStore.recordEnd(opts.movementId, {
@@ -370,7 +373,16 @@ function normalizeOptions(opts) {
370
373
  (process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions'));
371
374
  opts.keepBatchFiles = !!opts.keepBatchFiles;
372
375
  opts.staged = !!opts.staged;
376
+ // A driver that matches upsert keys only in a staged move (DuckDB: its
377
+ // written files keep no index) stages every upsert, asked or not.
378
+ if (opts.driver && opts.driver.stagesUpserts && (opts.primaryKeys || []).length) opts.staged = true;
373
379
  opts.writeTable = null;
380
+ opts.replaceFrom = opts.replaceFrom || null;
381
+ if (opts.replaceFrom) {
382
+ if (!opts.replaceFrom.column) throw new Error('upload: replaceFrom needs a column');
383
+ if ((opts.primaryKeys || []).length) throw new Error('upload: replaceFrom replaces a range, so it takes no primaryKeys');
384
+ opts.staged = true;
385
+ }
374
386
  if (opts.staged && typeof opts.driver.mergeStaged !== 'function') {
375
387
  throw new Error('upload: staged loads are not supported for this database — its driver has no mergeStaged');
376
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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",