@xeplr/actions 1.1.17 → 1.1.19
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 +65 -7
- package/index.js +2 -0
- package/lib/builtins/db/move.js +8 -1
- package/lib/builtins/export/pages.js +92 -0
- package/lib/builtins/export/table.js +116 -0
- package/lib/builtins/file/upload.js +6 -1
- package/lib/builtins/index.js +8 -1
- package/lib/drivers/db/duckdb.js +96 -1
- package/lib/drivers/db/mssql.js +3 -2
- package/lib/drivers/db/mysql.js +2 -2
- package/lib/drivers/db/postgres.js +3 -2
- package/lib/formats/index.js +3 -1
- package/lib/formats/writers/csv.js +78 -0
- package/lib/formats/writers/index.js +23 -0
- package/lib/formats/writers/pdf.js +293 -0
- package/lib/formats/writers/xlsx.js +278 -0
- package/lib/uploader/index.js +13 -1
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -107,6 +107,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
107
107
|
| key | action name | does |
|
|
108
108
|
|---|---|---|
|
|
109
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). |
|
|
110
|
+
| `exportTable` | `export-table` | Rows to a **CSV, Excel (.xlsx) or PDF** file. See [Export](#export). |
|
|
111
|
+
| `exportPages` | `export-pages` | Pages of placed blocks (text, tables, images, shapes) to a PDF — a dashboard as it looked. See [Export](#export). |
|
|
110
112
|
| `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 }`. |
|
|
111
113
|
| `dbPush` | `db-push` | Write `rows` or an NDJSON `filePath` into `targetTable` through the uploader. `primaryKeys` → upsert. |
|
|
112
114
|
| `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). |
|
|
@@ -114,8 +116,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
114
116
|
| `dbListTables` | `db-list-tables` | `{ tables }` — base tables in a schema/database. |
|
|
115
117
|
| `dbListViews` | `db-list-views` | `{ views }`. |
|
|
116
118
|
| `dbListProcedures` | `db-list-procedures` | `{ procedures }` (functions excluded). |
|
|
117
|
-
| `dbListColumns` | `db-list-columns` | `{ columns }` — name and
|
|
118
|
-
| `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`). |
|
|
119
|
+
| `dbListColumns` | `db-list-columns` | `{ columns }` — `name`, `dataType`, `udtName` and `maxLength` (characters; `-1` is SQL Server `(max)`, `null` when it has none) for one table. |
|
|
120
|
+
| `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. |
|
|
119
121
|
| `sendEmail` | `email-send` | Send one message. See [Email](#email). |
|
|
120
122
|
| `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. |
|
|
121
123
|
| `emailMove` | `email-move` | Move a message (`messageId`, `folder`) to `toFolder`. |
|
|
@@ -131,7 +133,7 @@ The db actions `db-fetch`, `db-push` and `db-procedure` accept a connection two
|
|
|
131
133
|
- `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`).
|
|
132
134
|
- `connection: { host, port, user, password, database }` and `dbType` — literal, for scripts, tests and callers with no host.
|
|
133
135
|
|
|
134
|
-
`db-move` takes literal `sourceConnection` / `targetConnection`; `file-upload` takes `dbConnection`. A DuckDB connection is `{ file, access?: 'ro' | 'rw', openTimeoutMs?: 60000, maxConnections?: 4 }
|
|
136
|
+
`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)).
|
|
135
137
|
|
|
136
138
|
### db-move
|
|
137
139
|
|
|
@@ -142,7 +144,8 @@ The db actions `db-fetch`, `db-push` and `db-procedure` accept a connection two
|
|
|
142
144
|
| `writeMode` | `append` (default), `replace` (TRUNCATE the target first if it exists, DELETE if TRUNCATE fails), `upsert` (**requires** `primaryKeys`). |
|
|
143
145
|
| `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. |
|
|
144
146
|
| `where` | Raw SQL condition, ANDed with the window in parentheses, run on the source. Not parameterised. Ignored for `mode: 'procedure'`. |
|
|
145
|
-
| `
|
|
147
|
+
| `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). |
|
|
148
|
+
| `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. |
|
|
146
149
|
| `movementId` | Correlation key. Default `system.occurrenceId`, else `mv_<id>`. |
|
|
147
150
|
| `batchSize`, `concurrency` | Default 5000 and 4. |
|
|
148
151
|
|
|
@@ -219,6 +222,48 @@ should not take the other eight hundred and ninety-nine with it.
|
|
|
219
222
|
**No aggregation.** `group by status, count` changes what a row *is*; that is
|
|
220
223
|
a different shape and, when it is wanted, a different step.
|
|
221
224
|
|
|
225
|
+
## Export
|
|
226
|
+
|
|
227
|
+
`export-table` writes rows that arrive **already presented**: which columns, in what order, under what heading, and for each cell its raw value and the text a reader saw. The action knows nothing of reports or pivots; the caller decides all that (in xeplr-bi, `report-engine`'s `presentTable` does). Each format takes what it needs:
|
|
228
|
+
|
|
229
|
+
| format | writes | from |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `csv` | RFC 4180; a UTF-8 byte-order mark so Excel reads `₹` and accents; a text cell starting with `= + - @` gets a leading `'` (no formula injection; numbers untouched) | `values` |
|
|
232
|
+
| `xlsx` | numbers and dates as **real typed cells carrying the column's Excel number format** (`"₹"#,##0.00`, `0.0%`, `mmm yyyy`, `#,##0.0,,"M"`), so the sheet looks like the screen and still sums; an optional title and subtitle; a bold, frozen header with an autofilter; bold subtotals and a tinted total row; column widths from the first 200 rows. Excel's 1,048,576-row limit is respected (`truncated`) | `values` |
|
|
233
|
+
| `pdf` | each cell exactly as displayed, numbers right-aligned by `align`, header repeated on every page, landscape past 6 columns, "Page x of y", bold or tinted total rows. Capped at 5,000 rows (`maxRows`) with a closing note | `display` |
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
await runAction(builtins.exportTable, {
|
|
237
|
+
format: 'xlsx',
|
|
238
|
+
document: {
|
|
239
|
+
title: 'Sales by region', subtitle: 'Date: Current financial year',
|
|
240
|
+
columns: [
|
|
241
|
+
{ header: 'Region' },
|
|
242
|
+
{ header: 'Month', kind: 'date', numFmt: 'mmm yyyy' },
|
|
243
|
+
{ header: 'Revenue', kind: 'number', numFmt: '"₹"#,##0.00', align: 'right' }
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
rows: [{ values: ['North', '2024-01-01', 1234.5], display: ['North', 'Jan 2024', '₹1,234.50'], kind: 'detail' }]
|
|
247
|
+
// or rowsFile: an NDJSON file, one row per line — streamed, never held
|
|
248
|
+
})
|
|
249
|
+
// → { filePath, fileName, contentType, bytes, rows, truncated }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **Streaming.** CSV and Excel stream rows to disk, from `rows` or from `rowsFile` (NDJSON, one row per line, as `streaming.spool` writes it); 200,000 rows to xlsx take about 2 s in flat memory. Our own xlsx writer runs on `fflate`. `exceljs` was rejected: it pulls a `uuid` with a published advisory and has had no release since December 2024.
|
|
253
|
+
- **Fonts.** pdfkit's built-in fonts cannot draw `₹` or non-Latin text; a missing glyph draws as nothing. The PDF embeds **DejaVu Sans Condensed** (the `dejavu-fonts-ttf` peer). Pass `document.font` / `boldFont` (TTF/OTF paths) for scripts it lacks. Only the glyphs used are embedded.
|
|
254
|
+
- **Output** goes to `outputPath`, or to a new directory under `XEPLR_ACTIONS_TMP_DIR` with a file named after the title. A failed export leaves no half-written file.
|
|
255
|
+
|
|
256
|
+
`export-pages` lays out `pages: [{ blocks }]`, each block positioned by **fractions** of the page's content box (A4 landscape by default):
|
|
257
|
+
- `text` (`text`, `size`, `bold`, `color`, `align`)
|
|
258
|
+
- `table` (`title`, `columns`, `rows`; drawn inside its box, and rows that don't fit are counted as "+N more rows")
|
|
259
|
+
- `image` (`src` must be a **PNG or JPEG data URL**: no fetching, no SVG, no markup)
|
|
260
|
+
- `rect` (`fill`, `stroke`, `radius`)
|
|
261
|
+
- `line`
|
|
262
|
+
|
|
263
|
+
What it is given is assumed to come from a browser, so it is bounded: 50 pages, 200 blocks a page, 8 MB per image, 500 table rows, and 5,000 characters of text. An image that will not decode draws a note in its box instead of failing the whole file.
|
|
264
|
+
|
|
265
|
+
Peers (optional, needed only by the format used): `fflate` for xlsx; `pdfkit` and `dejavu-fonts-ttf` for pdf. `writers` (`getWriter`, `INFO`, `SUPPORTED`) is exported for code that wants a writer without the action.
|
|
266
|
+
|
|
222
267
|
## Uploader
|
|
223
268
|
|
|
224
269
|
`uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
|
|
@@ -245,7 +290,7 @@ await actions.uploader.upload({
|
|
|
245
290
|
|
|
246
291
|
`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.
|
|
247
292
|
|
|
248
|
-
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
|
|
293
|
+
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.
|
|
249
294
|
|
|
250
295
|
Logical types and what each driver creates:
|
|
251
296
|
|
|
@@ -268,9 +313,22 @@ With `staged: true` a load **never writes into its target while it runs**. Batch
|
|
|
268
313
|
- **Crash during the move**: the transaction is discarded on reopen and the target is as it was.
|
|
269
314
|
- **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.
|
|
270
315
|
- **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.
|
|
316
|
+
- **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.
|
|
271
317
|
- **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.
|
|
272
318
|
|
|
273
|
-
A driver opts in by implementing `prepareStaging`, `mergeStaged`, `dropStaging` and `committedMovement`; `upload` refuses `staged` for one that does not.
|
|
319
|
+
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.
|
|
320
|
+
|
|
321
|
+
### A written DuckDB file keeps no index
|
|
322
|
+
|
|
323
|
+
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.
|
|
324
|
+
|
|
325
|
+
So the DuckDB driver, on every **read-write** open of a file:
|
|
326
|
+
|
|
327
|
+
1. runs `CHECKPOINT` first, which folds a replayed WAL in safely before anything touches it (the upstream workaround);
|
|
328
|
+
2. **drops every index** in the file, logging which, so one left from before this rule cannot be what a later crash breaks;
|
|
329
|
+
3. **refuses `CREATE INDEX`** on that pool from then on (`ensureUpsertIndex` included).
|
|
330
|
+
|
|
331
|
+
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.
|
|
274
332
|
|
|
275
333
|
`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`.
|
|
276
334
|
|
|
@@ -380,7 +438,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
380
438
|
| needs nothing | needs a database |
|
|
381
439
|
|---|---|
|
|
382
440
|
| `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` |
|
|
383
|
-
| `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` |
|
|
441
|
+
| `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` |
|
|
384
442
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
385
443
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
|
386
444
|
|
package/index.js
CHANGED
|
@@ -41,6 +41,8 @@ module.exports = {
|
|
|
41
41
|
streaming: streaming,
|
|
42
42
|
drivers: { db: dbDrivers, file: fileDrivers },
|
|
43
43
|
formats: formats,
|
|
44
|
+
// { getWriter(type), INFO, SUPPORTED } — csv / xlsx / pdf writers behind export-table.
|
|
45
|
+
writers: require('./lib/formats/writers'),
|
|
44
46
|
|
|
45
47
|
// xeplr_configs — shared control-plane DB (import/movement metadata).
|
|
46
48
|
// One call per app: attachConfig({ service }); await xcfg.ready(). See
|
package/lib/builtins/db/move.js
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// export-pages — placed blocks to a PDF: a dashboard as it looked.
|
|
2
|
+
//
|
|
3
|
+
// Each page is a list of blocks positioned by FRACTIONS of the page's content
|
|
4
|
+
// box (x, w across; y, h down), so a layout drawn on any screen keeps its
|
|
5
|
+
// proportions on paper. A block is text, a small table, an image (a PNG or
|
|
6
|
+
// JPEG data URL — a chart is captured as one), a rectangle or a line. See
|
|
7
|
+
// lib/formats/writers/pdf.js renderPages for each block's fields.
|
|
8
|
+
//
|
|
9
|
+
// Bounded on purpose: what arrives here was posted by a browser, so the page,
|
|
10
|
+
// block and image counts and sizes are capped and an image must be a data URL
|
|
11
|
+
// of a raster type — no fetching, no markup.
|
|
12
|
+
|
|
13
|
+
var fs = require('fs');
|
|
14
|
+
var os = require('os');
|
|
15
|
+
var path = require('path');
|
|
16
|
+
var pdf = require('../../formats/writers/pdf');
|
|
17
|
+
var { checkFormatRequires } = require('../../formats');
|
|
18
|
+
|
|
19
|
+
var LIMITS = { pages: 50, blocksPerPage: 200, imageBytes: 8 * 1024 * 1024, tableRows: 500, text: 5000 };
|
|
20
|
+
var TYPES = ['text', 'table', 'image', 'rect', 'line'];
|
|
21
|
+
|
|
22
|
+
function fail(message) {
|
|
23
|
+
var err = new Error(message);
|
|
24
|
+
err.status = 400;
|
|
25
|
+
return err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fraction(n) { return typeof n === 'number' && isFinite(n) && n >= -0.01 && n <= 50; }
|
|
29
|
+
|
|
30
|
+
/** Throws (400) naming the first block that is not one this action draws. */
|
|
31
|
+
function validate(spec) {
|
|
32
|
+
var pages = spec.pages;
|
|
33
|
+
if (!Array.isArray(pages) || !pages.length) throw fail('pages must list at least one page');
|
|
34
|
+
if (pages.length > LIMITS.pages) throw fail('At most ' + LIMITS.pages + ' pages');
|
|
35
|
+
pages.forEach(function(page, p) {
|
|
36
|
+
var blocks = (page && page.blocks) || [];
|
|
37
|
+
if (!Array.isArray(blocks)) throw fail('pages[' + p + '].blocks must be a list');
|
|
38
|
+
if (blocks.length > LIMITS.blocksPerPage) throw fail('At most ' + LIMITS.blocksPerPage + ' blocks on a page');
|
|
39
|
+
blocks.forEach(function(b, i) {
|
|
40
|
+
var where = 'pages[' + p + '].blocks[' + i + ']';
|
|
41
|
+
if (!b || TYPES.indexOf(b.type) === -1) throw fail(where + ': type must be one of ' + TYPES.join(', '));
|
|
42
|
+
if (![b.x, b.y, b.w, b.h].every(fraction)) throw fail(where + ': x, y, w and h are fractions of the page');
|
|
43
|
+
if (b.type === 'image') {
|
|
44
|
+
var src = String(b.src || '');
|
|
45
|
+
if (!/^data:image\/(png|jpe?g);base64,[A-Za-z0-9+/=]+$/.test(src)) throw fail(where + ': src must be a PNG or JPEG data URL');
|
|
46
|
+
if (src.length * 0.75 > LIMITS.imageBytes) throw fail(where + ': image is larger than ' + (LIMITS.imageBytes >> 20) + ' MB');
|
|
47
|
+
}
|
|
48
|
+
if (b.type === 'table') {
|
|
49
|
+
if (!Array.isArray(b.columns) || !Array.isArray(b.rows)) throw fail(where + ': a table needs columns and rows');
|
|
50
|
+
if (b.rows.length > LIMITS.tableRows) b.rows = b.rows.slice(0, LIMITS.tableRows);
|
|
51
|
+
}
|
|
52
|
+
if (b.type === 'text' && String(b.text == null ? '' : b.text).length > LIMITS.text) b.text = String(b.text).slice(0, LIMITS.text);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
name: 'export-pages',
|
|
59
|
+
description: 'Lay out pages of placed blocks (text, tables, images, shapes) as a PDF — a dashboard as it looked.',
|
|
60
|
+
requires: [],
|
|
61
|
+
LIMITS: LIMITS,
|
|
62
|
+
|
|
63
|
+
inputSchema: [
|
|
64
|
+
{ name: 'pages', type: 'array', required: true, order: 1,
|
|
65
|
+
description: '[{ blocks: [{ type: text|table|image|rect|line, x, y, w, h, … }] }], positions as fractions of the page.' },
|
|
66
|
+
{ name: 'title', type: 'string', order: 2 },
|
|
67
|
+
{ name: 'footer', type: 'string', order: 3 },
|
|
68
|
+
{ name: 'landscape', type: 'boolean', order: 4, description: 'Default true.' },
|
|
69
|
+
{ name: 'outputPath', type: 'string', order: 5 }
|
|
70
|
+
],
|
|
71
|
+
outputSchema: [
|
|
72
|
+
{ name: 'filePath', type: 'string' }, { name: 'fileName', type: 'string' },
|
|
73
|
+
{ name: 'contentType', type: 'string' }, { name: 'bytes', type: 'number' }, { name: 'pages', type: 'number' }
|
|
74
|
+
],
|
|
75
|
+
|
|
76
|
+
execute: async function(ctx) {
|
|
77
|
+
var input = ctx.input || {};
|
|
78
|
+
checkFormatRequires('export-pages', 'pdf', pdf);
|
|
79
|
+
validate(input);
|
|
80
|
+
var name = (String(input.title || 'dashboard').replace(/[\\/:*?"<>|\u0000-\u001F]+/g, ' ').trim().slice(0, 80) || 'dashboard') + '.pdf';
|
|
81
|
+
var dir = process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
var filePath = input.outputPath || path.join(fs.mkdtempSync(path.join(dir, 'export-')), name);
|
|
84
|
+
var done = await pdf.renderPages(filePath, {
|
|
85
|
+
pages: input.pages, title: input.title, footer: input.footer, landscape: input.landscape !== false
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
filePath: filePath, fileName: path.basename(filePath), contentType: 'application/pdf',
|
|
89
|
+
bytes: fs.statSync(filePath).size, pages: done.pages
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// export-table — rows to a CSV, Excel or PDF file.
|
|
2
|
+
//
|
|
3
|
+
// The rows arrive already DECIDED: which columns, in what order, under what
|
|
4
|
+
// heading, and for each cell both its raw value and the text a reader saw.
|
|
5
|
+
// This action does not know what a report or a pivot is; whoever calls it
|
|
6
|
+
// worked that out (in BI, report-engine's presentTable). Each format takes
|
|
7
|
+
// what it needs from a row:
|
|
8
|
+
//
|
|
9
|
+
// csv the raw values, unformatted — a file for another program
|
|
10
|
+
// xlsx the raw values as TYPED cells carrying each column's number format,
|
|
11
|
+
// so the sheet looks like the screen and still sums
|
|
12
|
+
// pdf the display text, exactly as shown
|
|
13
|
+
//
|
|
14
|
+
// Rows come inline (`rows`) or from an NDJSON file (`rowsFile`, one row per
|
|
15
|
+
// line — what streaming/spool writes), so a large result never has to be one
|
|
16
|
+
// array in memory. CSV and Excel stream; a PDF holds its rows (capped, 5,000 by
|
|
17
|
+
// default) because a page is laid out from them.
|
|
18
|
+
|
|
19
|
+
var fs = require('fs');
|
|
20
|
+
var os = require('os');
|
|
21
|
+
var path = require('path');
|
|
22
|
+
var readline = require('readline');
|
|
23
|
+
var writers = require('../../formats/writers');
|
|
24
|
+
var { checkFormatRequires } = require('../../formats');
|
|
25
|
+
|
|
26
|
+
function tmpDir() {
|
|
27
|
+
var dir = process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A file name from a title: what a person would recognise in their downloads. */
|
|
33
|
+
function fileNameFor(title, extension) {
|
|
34
|
+
var base = String(title || 'export').replace(/[\\/:*?"<>|\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'export';
|
|
35
|
+
return base + '.' + extension;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function* rowsFrom(input) {
|
|
39
|
+
if (Array.isArray(input.rows)) { for (var i = 0; i < input.rows.length; i++) yield input.rows[i]; return; }
|
|
40
|
+
var lines = readline.createInterface({ input: fs.createReadStream(input.rowsFile), crlfDelay: Infinity });
|
|
41
|
+
for await (var line of lines) if (line.trim()) yield JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
name: 'export-table',
|
|
46
|
+
description: 'Write rows to a CSV, Excel (.xlsx) or PDF file. Excel cells stay numbers with their number format; ' +
|
|
47
|
+
'a PDF shows each cell as it was displayed.',
|
|
48
|
+
requires: [],
|
|
49
|
+
|
|
50
|
+
inputSchema: [
|
|
51
|
+
{ name: 'format', type: 'string', required: true, order: 1, enum: writers.SUPPORTED,
|
|
52
|
+
description: 'csv, xlsx or pdf.' },
|
|
53
|
+
{ name: 'document', type: 'object', required: true, order: 2,
|
|
54
|
+
description: '{ title?, subtitle?, footer?, columns: [{ header, kind?: number|date|text, numFmt?, align?, width? }] }. ' +
|
|
55
|
+
'numFmt is an Excel number format ("#,##0.00", "0.0%", "mmm yyyy").' },
|
|
56
|
+
{ name: 'rows', type: 'array', order: 3,
|
|
57
|
+
description: 'Rows: { values: [], display?: [], kind?: detail|subtotal|total }. Or give rowsFile.' },
|
|
58
|
+
{ name: 'rowsFile', type: 'string', order: 4,
|
|
59
|
+
description: 'An NDJSON file with one row per line, instead of rows.' },
|
|
60
|
+
{ name: 'outputPath', type: 'string', order: 5,
|
|
61
|
+
description: 'Where to write. Default: a new file in XEPLR_ACTIONS_TMP_DIR named after the title.' },
|
|
62
|
+
{ name: 'maxRows', type: 'number', order: 6,
|
|
63
|
+
description: 'PDF only: rows to draw before stopping with a note (default 5,000).' }
|
|
64
|
+
],
|
|
65
|
+
outputSchema: [
|
|
66
|
+
{ name: 'filePath', type: 'string' }, { name: 'fileName', type: 'string' },
|
|
67
|
+
{ name: 'contentType', type: 'string' }, { name: 'bytes', type: 'number' },
|
|
68
|
+
{ name: 'rows', type: 'number' }, { name: 'truncated', type: 'boolean' }
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
execute: async function(ctx) {
|
|
72
|
+
var input = ctx.input || {};
|
|
73
|
+
var format = String(input.format || '').toLowerCase();
|
|
74
|
+
var writer = writers.getWriter(format);
|
|
75
|
+
checkFormatRequires('export-table', format, writer);
|
|
76
|
+
var doc = Object.assign({}, input.document || {});
|
|
77
|
+
if (!Array.isArray(doc.columns) || !doc.columns.length) {
|
|
78
|
+
var bad = new Error('document.columns must list at least one column');
|
|
79
|
+
bad.status = 400;
|
|
80
|
+
throw bad;
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(input.rows) && !input.rowsFile) {
|
|
83
|
+
var none = new Error('Give rows, or a rowsFile');
|
|
84
|
+
none.status = 400;
|
|
85
|
+
throw none;
|
|
86
|
+
}
|
|
87
|
+
if (format === 'pdf' && input.maxRows) doc.maxRows = input.maxRows;
|
|
88
|
+
|
|
89
|
+
var info = writers.INFO[format];
|
|
90
|
+
var fileName = fileNameFor(doc.title, info.extension);
|
|
91
|
+
var filePath = input.outputPath || path.join(fs.mkdtempSync(path.join(tmpDir(), 'export-')), fileName);
|
|
92
|
+
var open = writer.open(filePath, doc);
|
|
93
|
+
var signal = ctx.signal;
|
|
94
|
+
try {
|
|
95
|
+
for await (var row of rowsFrom(input)) {
|
|
96
|
+
if (signal && signal.aborted) throw new Error('Stopped by request');
|
|
97
|
+
var more = await open.writeRow(row);
|
|
98
|
+
// Excel's own limit: the rest are only counted. A PDF keeps counting
|
|
99
|
+
// too, so its closing note can say how many were left out.
|
|
100
|
+
if (more === false && format === 'xlsx') { /* keep reading to count */ }
|
|
101
|
+
}
|
|
102
|
+
var done = await open.close();
|
|
103
|
+
return {
|
|
104
|
+
filePath: filePath,
|
|
105
|
+
fileName: path.basename(filePath),
|
|
106
|
+
contentType: info.contentType,
|
|
107
|
+
bytes: fs.statSync(filePath).size,
|
|
108
|
+
rows: done.rows,
|
|
109
|
+
truncated: done.truncated
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
try { fs.rmSync(filePath, { force: true }); } catch (e) { /* nothing to clean */ }
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
@@ -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();
|
package/lib/builtins/index.js
CHANGED
|
@@ -61,5 +61,12 @@ module.exports = {
|
|
|
61
61
|
emailMove: require('./email/move'),
|
|
62
62
|
emailDelete: require('./email/delete'),
|
|
63
63
|
emailDownloadEmail: require('./email/download-email'),
|
|
64
|
-
emailDownloadAttachments: require('./email/download-attachments')
|
|
64
|
+
emailDownloadAttachments: require('./email/download-attachments'),
|
|
65
|
+
|
|
66
|
+
// Export — rows to CSV / Excel / PDF, and placed blocks to a PDF. The rows
|
|
67
|
+
// arrive already presented (columns, headings, raw values and display
|
|
68
|
+
// text); see export/table.js. Peers: fflate (xlsx), pdfkit and
|
|
69
|
+
// dejavu-fonts-ttf (pdf) — optional, required only by the format used.
|
|
70
|
+
exportTable: require('./export/table'),
|
|
71
|
+
exportPages: require('./export/pages')
|
|
65
72
|
};
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -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,
|
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -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
|
|
package/lib/formats/index.js
CHANGED
|
@@ -21,8 +21,10 @@ function getFormat(type) {
|
|
|
21
21
|
function checkFormatRequires(actionName, format, mod) {
|
|
22
22
|
var requires = (mod && mod.requires) || [];
|
|
23
23
|
for (var i = 0; i < requires.length; i++) {
|
|
24
|
+
// A package of data files (a font) has no main to resolve; its
|
|
25
|
+
// package.json says it is installed just as well.
|
|
24
26
|
try { require.resolve(requires[i]); }
|
|
25
|
-
catch (_) {
|
|
27
|
+
catch (_) { try { require.resolve(requires[i] + '/package.json'); continue; } catch (__) { /* missing — fall through */ }
|
|
26
28
|
throw new Error(
|
|
27
29
|
actionName + ' requires "' + requires[i] + '" for format="' + format + '". ' +
|
|
28
30
|
'Install with: npm install ' + requires[i]
|