@xeplr/actions 1.1.18 → 1.1.20
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 +57 -2
- package/index.js +2 -0
- package/lib/builtins/export/pages.js +92 -0
- package/lib/builtins/export/table.js +116 -0
- package/lib/builtins/index.js +8 -1
- package/lib/drivers/db/dateValues.js +106 -0
- package/lib/drivers/db/duckdb.js +32 -20
- package/lib/drivers/db/mssql.js +21 -32
- package/lib/drivers/db/mysql.js +23 -32
- package/lib/drivers/db/postgres.js +32 -23
- 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 +284 -0
- package/lib/uploader/index.js +14 -0
- package/lib/uploader/reconcile.js +2 -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). |
|
|
@@ -220,6 +222,48 @@ should not take the other eight hundred and ninety-nine with it.
|
|
|
220
222
|
**No aggregation.** `group by status, count` changes what a row *is*; that is
|
|
221
223
|
a different shape and, when it is wanted, a different step.
|
|
222
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
|
+
|
|
223
267
|
## Uploader
|
|
224
268
|
|
|
225
269
|
`uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
|
|
@@ -256,9 +300,20 @@ Logical types and what each driver creates:
|
|
|
256
300
|
| number | NUMERIC | DECIMAL(38,10) | DECIMAL(38,10) | DOUBLE |
|
|
257
301
|
| boolean | BOOLEAN | TINYINT(1) | BIT | BOOLEAN |
|
|
258
302
|
| date | DATE | DATE | DATE | DATE |
|
|
259
|
-
| datetime | TIMESTAMPTZ | DATETIME(6) | DATETIME2 | TIMESTAMPTZ |
|
|
303
|
+
| datetime (an instant) | TIMESTAMPTZ | DATETIME(6), in UTC | DATETIME2, in UTC | TIMESTAMPTZ |
|
|
304
|
+
| localdatetime (no zone) | TIMESTAMP | DATETIME(6) | DATETIME2 | TIMESTAMP |
|
|
260
305
|
| object / array | JSONB | JSON | NVARCHAR(MAX) | JSON |
|
|
261
306
|
|
|
307
|
+
**Dates and times follow three rules, on every driver** (`lib/drivers/db/dateValues.js`, the one copy):
|
|
308
|
+
|
|
309
|
+
1. **A `date` is copied as the same day.** MySQL/Postgres/SQL Server `DATE` → `date`.
|
|
310
|
+
2. **A date-time with a zone is copied as the same instant**, stored in UTC. MySQL `TIMESTAMP`, Postgres `timestamptz`, SQL Server `datetimeoffset`, DuckDB `TIMESTAMPTZ` → `datetime`.
|
|
311
|
+
3. **A date-time without a zone is copied as its digits**, `10:00` stays `10:00`, and stays zoneless. MySQL `DATETIME`, Postgres `timestamp`, SQL Server `datetime`/`datetime2`/`smalldatetime`, DuckDB `TIMESTAMP` → `localdatetime`. Which zone it was recorded in is declared where it is used (a cube's source zone), never guessed here.
|
|
312
|
+
|
|
313
|
+
**Nothing reads the machine's time zone.** The MySQL session is pinned to UTC (`SET time_zone = '+00:00'` on every connection), SQL Server to `useUTC: true` (after the caller's options), Postgres returns `date` and `timestamp` as text, and DuckDB opens with `TimeZone = 'UTC'`. Every JS `Date` is read with `getUTC*`, and a string with no zone is read as UTC, never handed to `new Date()`. `test/dates-across-zones.test.js` moves a real table from each of the three databases into DuckDB with the process in India time and in New York time, and the two must store the same values.
|
|
314
|
+
|
|
315
|
+
**An existing target column that is zoned but receives a zoneless source column is retyped once** to zoneless, keeping its digits (Postgres and DuckDB; `buildRetypeToLocalDateTimeSql`). Tables created before this rule held those values as instants.
|
|
316
|
+
|
|
262
317
|
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`.
|
|
263
318
|
|
|
264
319
|
### Staged loads
|
|
@@ -377,7 +432,7 @@ await driver.query(pool, userSql, params) // reads only
|
|
|
377
432
|
| 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. |
|
|
378
433
|
| 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. |
|
|
379
434
|
| **Every DuckDB value comes out plain**, from `query` and `fetchStream` alike: a number, text, boolean, `Date`, array or object, never a BigInt or a DuckDB wrapper object. DECIMAL is a number; DATE and TIMESTAMP are `Date`s; a whole number too big to hold exactly (beyond ±9,007,199,254,740,991) is **text**, never rounded; lists, structs and maps are arrays and objects; TIME, INTERVAL, UUID and any other type are text. `fetchStream` has DuckDB do the conversion inside its engine by wrapping the query (about +3% on a million rows); a query with nothing to convert runs as given. | A streamed read used to hand DECIMAL and TIMESTAMP back as wrapper objects with a BigInt inside, and the first `JSON.stringify` downstream failed with "Do not know how to serialize a BigInt". Text for huge numbers is what the Postgres, MySQL and SQL Server drivers already return. |
|
|
380
|
-
| **A
|
|
435
|
+
| **DuckDB works in UTC** (`TimeZone = 'UTC'` on every instance). A timestamp without a zone is read with its digits in the `Date`'s UTC fields; one with a zone keeps its exact instant. | Left to the machine's zone, `date_trunc('month', …)` of 1 Aug 00:00 UTC on a server in India was 31 Jul 18:30, so August was labelled July, and a filter "to 31 Aug" ended at 30 Aug 18:30. |
|
|
381
436
|
| `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. |
|
|
382
437
|
| 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. |
|
|
383
438
|
| 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. |
|
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
|
|
@@ -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
|
+
};
|
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
|
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Dates and times as they move between databases — the ONE copy every driver
|
|
2
|
+
// uses, so no driver can quietly read a value in this machine's zone.
|
|
3
|
+
//
|
|
4
|
+
// Three logical types, and three rules:
|
|
5
|
+
//
|
|
6
|
+
// date a calendar day. Copied as the same day, always.
|
|
7
|
+
// datetime an INSTANT (MySQL TIMESTAMP, Postgres timestamptz, SQL Server
|
|
8
|
+
// datetimeoffset, DuckDB TIMESTAMPTZ). Copied as the same
|
|
9
|
+
// instant, in UTC.
|
|
10
|
+
// localdatetime a wall-clock reading with NO zone (MySQL DATETIME, Postgres
|
|
11
|
+
// timestamp, SQL Server datetime/datetime2, DuckDB TIMESTAMP).
|
|
12
|
+
// Copied digit for digit — 10:00 stays 10:00. What zone it was
|
|
13
|
+
// recorded in is declared later, where it is used (a cube's
|
|
14
|
+
// source timezone), never guessed here.
|
|
15
|
+
//
|
|
16
|
+
// NOTHING HERE READS THE MACHINE'S ZONE. Every Date is read with getUTC*, and a
|
|
17
|
+
// string without a zone is read as UTC rather than handed to `new Date()`,
|
|
18
|
+
// which would read it in local time. The drivers deliver values on the same
|
|
19
|
+
// terms: the MySQL session is pinned to UTC, the SQL Server driver to useUTC,
|
|
20
|
+
// Postgres returns its zoneless types as text, and DuckDB works in UTC. So a
|
|
21
|
+
// value here means the same thing on a server in India and one in New York —
|
|
22
|
+
// the tests run under both zones to keep it that way.
|
|
23
|
+
|
|
24
|
+
function pad(n, width) { return String(n).padStart(width || 2, '0'); }
|
|
25
|
+
|
|
26
|
+
function utcDate(d) {
|
|
27
|
+
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function utcClock(d) {
|
|
31
|
+
return pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) + '.' + pad(d.getUTCMilliseconds(), 3);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// '2025-08-01', '2025-08-01 10:00', '2025-08-01T10:00:00.123456', optionally with
|
|
35
|
+
// 'Z' or an offset. Captures the parts as written.
|
|
36
|
+
var STAMP = /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A calendar day as YYYY-MM-DD. A Date is read in UTC — every driver delivers a
|
|
40
|
+
* date as midnight UTC of that day, or as text.
|
|
41
|
+
*/
|
|
42
|
+
function toDateOnly(v) {
|
|
43
|
+
if (v === null || v === undefined) return v;
|
|
44
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? null : utcDate(v);
|
|
45
|
+
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v).trim());
|
|
46
|
+
return m ? m[1] : v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A zoneless wall-clock time as 'YYYY-MM-DD HH:MM:SS.mmm', the digits it had.
|
|
51
|
+
* A Date carries those digits in its UTC fields (that is how the drivers hand
|
|
52
|
+
* one over); a string keeps its own digits, and any zone on it is dropped —
|
|
53
|
+
* the column has none to keep.
|
|
54
|
+
*/
|
|
55
|
+
function toLocalDateTime(v) {
|
|
56
|
+
if (v === null || v === undefined) return v;
|
|
57
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? null : utcDate(v) + ' ' + utcClock(v);
|
|
58
|
+
var m = STAMP.exec(String(v).trim());
|
|
59
|
+
if (!m) return v;
|
|
60
|
+
var ms = m[5] ? (m[5] + '00').slice(0, 3) : '000';
|
|
61
|
+
return m[1] + ' ' + (m[2] || '00') + ':' + (m[3] || '00') + ':' + (m[4] || '00') + '.' + ms;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An instant as a Date. A string that names its zone is read in it; one that
|
|
66
|
+
* does not is read as UTC (a MySQL TIMESTAMP from a UTC session), never in this
|
|
67
|
+
* machine's zone. Unreadable → null.
|
|
68
|
+
*/
|
|
69
|
+
function toInstant(v) {
|
|
70
|
+
if (v === null || v === undefined) return null;
|
|
71
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
|
72
|
+
var m = STAMP.exec(String(v).trim());
|
|
73
|
+
if (!m) return null;
|
|
74
|
+
var iso = m[1] + 'T' + (m[2] || '00') + ':' + (m[3] || '00') + ':' + (m[4] || '00') +
|
|
75
|
+
(m[5] ? '.' + (m[5] + '00').slice(0, 3) : '') + (m[6] ? normalizeZone(m[6]) : 'Z');
|
|
76
|
+
var d = new Date(iso);
|
|
77
|
+
return isNaN(d.getTime()) ? null : d;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeZone(z) {
|
|
81
|
+
if (/^z$/i.test(z)) return 'Z';
|
|
82
|
+
var m = /^([+-])(\d{2}):?(\d{2})?$/.exec(z);
|
|
83
|
+
return m ? m[1] + m[2] + ':' + (m[3] || '00') : 'Z';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An instant as ISO 8601 in UTC ('…Z'), or the value unchanged when unreadable. */
|
|
87
|
+
function toInstantIso(v) {
|
|
88
|
+
if (v === null || v === undefined) return v;
|
|
89
|
+
var d = toInstant(v);
|
|
90
|
+
return d ? d.toISOString() : v;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An instant as 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — for a column with no zone of its own (MySQL, SQL Server). */
|
|
94
|
+
function toInstantUtcClock(v) {
|
|
95
|
+
if (v === null || v === undefined) return v;
|
|
96
|
+
var d = toInstant(v);
|
|
97
|
+
return d ? utcDate(d) + ' ' + utcClock(d) : v;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
toDateOnly: toDateOnly,
|
|
102
|
+
toLocalDateTime: toLocalDateTime,
|
|
103
|
+
toInstant: toInstant,
|
|
104
|
+
toInstantIso: toInstantIso,
|
|
105
|
+
toInstantUtcClock: toInstantUtcClock
|
|
106
|
+
};
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -81,7 +81,8 @@ var TYPE_MAP = {
|
|
|
81
81
|
number: 'DOUBLE',
|
|
82
82
|
boolean: 'BOOLEAN',
|
|
83
83
|
date: 'DATE',
|
|
84
|
-
datetime: 'TIMESTAMPTZ',
|
|
84
|
+
datetime: 'TIMESTAMPTZ', // an instant
|
|
85
|
+
localdatetime: 'TIMESTAMP', // no zone; its digits as they were (dateValues.js)
|
|
85
86
|
object: 'JSON',
|
|
86
87
|
array: 'JSON'
|
|
87
88
|
};
|
|
@@ -94,8 +95,10 @@ var DUCK_TO_LOGICAL = {
|
|
|
94
95
|
'HUGEINT': 'number', 'UTINYINT': 'number', 'USMALLINT': 'number', 'UINTEGER': 'number',
|
|
95
96
|
'UBIGINT': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'DECIMAL': 'number',
|
|
96
97
|
'DATE': 'date',
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
// A TIMESTAMP has no zone — a wall-clock reading, copied as its digits. Only
|
|
99
|
+
// the one WITH a zone is an instant.
|
|
100
|
+
'TIMESTAMP': 'localdatetime', 'TIMESTAMP WITH TIME ZONE': 'datetime',
|
|
101
|
+
'TIMESTAMP_S': 'localdatetime', 'TIMESTAMP_MS': 'localdatetime', 'TIMESTAMP_NS': 'localdatetime',
|
|
99
102
|
'TIME': 'datetime', 'TIME WITH TIME ZONE': 'datetime', 'INTERVAL': 'string',
|
|
100
103
|
'JSON': 'object', 'STRUCT': 'object', 'MAP': 'object',
|
|
101
104
|
'LIST': 'array'
|
|
@@ -234,7 +237,15 @@ async function connect(config) {
|
|
|
234
237
|
return existing.pool;
|
|
235
238
|
}
|
|
236
239
|
|
|
237
|
-
|
|
240
|
+
// THE SESSION ZONE IS UTC, ALWAYS. DuckDB otherwise takes the machine's
|
|
241
|
+
// zone, and every date operation on a TIMESTAMPTZ — date_trunc, comparing
|
|
242
|
+
// with a plain date, a cast to DATE — happens in it. On a server in India
|
|
243
|
+
// the month of 1 Aug 00:00 UTC came back as 31 Jul 18:30, a board asked for
|
|
244
|
+
// August labelled its total "Jul-2025", and a filter "to 31 Aug" ended at
|
|
245
|
+
// 30 Aug 18:30. Data here is kept in UTC (and a cube stores its days already
|
|
246
|
+
// cut in its own reporting zone), so UTC is the zone that changes nothing.
|
|
247
|
+
// Set on the instance, so every connection to the file inherits it.
|
|
248
|
+
var options = Object.assign({ TimeZone: 'UTC' }, access === 'ro' ? { access_mode: 'READ_ONLY' } : {});
|
|
238
249
|
// Waits rather than failing — see openWithWait. This is what makes the
|
|
239
250
|
// file's own lock the mutex and removes any need to designate a writer.
|
|
240
251
|
var instance = await openWithWait(api, key, options, access,
|
|
@@ -516,19 +527,8 @@ function buildInsertSql(tableName, rows, columns, movementId, primaryKeys) {
|
|
|
516
527
|
return { sql: sql, params: params };
|
|
517
528
|
}
|
|
518
529
|
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
function toDateOnly(v) {
|
|
522
|
-
if (v === null || v === undefined) return v;
|
|
523
|
-
if (v instanceof Date) {
|
|
524
|
-
if (isNaN(v.getTime())) return null;
|
|
525
|
-
return v.getFullYear() + '-' +
|
|
526
|
-
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
527
|
-
String(v.getDate()).padStart(2, '0');
|
|
528
|
-
}
|
|
529
|
-
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
530
|
-
return m ? m[1] : v;
|
|
531
|
-
}
|
|
530
|
+
// Dates and times: one shared rule for every driver (dateValues.js).
|
|
531
|
+
var dateValues = require('./dateValues');
|
|
532
532
|
|
|
533
533
|
// Coerce a JS value to the effective column type. Same contract as the other
|
|
534
534
|
// drivers: a value that cannot fit passes through unchanged so DuckDB rejects
|
|
@@ -567,11 +567,13 @@ function coerceValue(v, type) {
|
|
|
567
567
|
return v;
|
|
568
568
|
|
|
569
569
|
case 'date':
|
|
570
|
-
return toDateOnly(v);
|
|
570
|
+
return dateValues.toDateOnly(v);
|
|
571
571
|
|
|
572
572
|
case 'datetime':
|
|
573
|
-
|
|
574
|
-
|
|
573
|
+
return dateValues.toInstantIso(v);
|
|
574
|
+
|
|
575
|
+
case 'localdatetime':
|
|
576
|
+
return dateValues.toLocalDateTime(v);
|
|
575
577
|
|
|
576
578
|
case 'object':
|
|
577
579
|
case 'array':
|
|
@@ -955,7 +957,17 @@ async function committedMovement(pool, movementId) {
|
|
|
955
957
|
return r.rows[0] || null;
|
|
956
958
|
}
|
|
957
959
|
|
|
960
|
+
/**
|
|
961
|
+
* A zoned date-time column that holds zoneless values, retyped to zoneless with
|
|
962
|
+
* the same digits (read in UTC — how they were written). See the uploader's
|
|
963
|
+
* bootstrap for when this runs.
|
|
964
|
+
*/
|
|
965
|
+
function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
966
|
+
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' SET DATA TYPE TIMESTAMP';
|
|
967
|
+
}
|
|
968
|
+
|
|
958
969
|
module.exports = {
|
|
970
|
+
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
959
971
|
requires: ['@duckdb/node-api'],
|
|
960
972
|
|
|
961
973
|
connect: connect,
|
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
// number → DECIMAL(38,10) (38 is SQL Server's max precision; > 10
|
|
23
23
|
// fractional digits are rounded)
|
|
24
24
|
// boolean → BIT
|
|
25
|
-
// date
|
|
26
|
-
// datetime→ DATETIME2
|
|
25
|
+
// date → DATE (the same day — dateValues.js)
|
|
26
|
+
// datetime → DATETIME2 (an instant, written in UTC)
|
|
27
|
+
// localdatetime → DATETIME2 (no zone; its digits as they were)
|
|
27
28
|
// object → NVARCHAR(MAX) (SQL Server has no native JSON type)
|
|
28
29
|
// array → NVARCHAR(MAX)
|
|
29
30
|
|
|
@@ -33,6 +34,7 @@ var TYPE_MAP = {
|
|
|
33
34
|
boolean: 'BIT',
|
|
34
35
|
date: 'DATE',
|
|
35
36
|
datetime: 'DATETIME2',
|
|
37
|
+
localdatetime: 'DATETIME2',
|
|
36
38
|
object: 'NVARCHAR(MAX)',
|
|
37
39
|
array: 'NVARCHAR(MAX)'
|
|
38
40
|
};
|
|
@@ -45,30 +47,16 @@ var NATIVE_TO_LOGICAL = {
|
|
|
45
47
|
decimal: 'number', numeric: 'number', float: 'number', real: 'number',
|
|
46
48
|
money: 'number', smallmoney: 'number',
|
|
47
49
|
bit: 'boolean',
|
|
48
|
-
|
|
50
|
+
// No zone: copied as their digits (dateValues.js). datetimeoffset is an
|
|
51
|
+
// instant.
|
|
52
|
+
datetime: 'localdatetime', datetime2: 'localdatetime', smalldatetime: 'localdatetime',
|
|
49
53
|
// 'date' maps to its OWN logical type — see reconcile.js.
|
|
50
54
|
date: 'date', time: 'datetime', datetimeoffset: 'datetime'
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
// toISOString() would convert to UTC first, which is exactly the bug: a Date
|
|
57
|
-
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
58
|
-
// Greenwich. The local getters are the right ones here precisely because a
|
|
59
|
-
// business date has no instant to convert.
|
|
60
|
-
function toDateOnly(v) {
|
|
61
|
-
if (v === null || v === undefined) return v;
|
|
62
|
-
if (v instanceof Date) {
|
|
63
|
-
if (isNaN(v.getTime())) return null;
|
|
64
|
-
return v.getFullYear() + '-' +
|
|
65
|
-
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
66
|
-
String(v.getDate()).padStart(2, '0');
|
|
67
|
-
}
|
|
68
|
-
// Already a date string — take the date part and leave the rest alone.
|
|
69
|
-
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
70
|
-
return m ? m[1] : v;
|
|
71
|
-
}
|
|
58
|
+
// Dates and times: one shared rule for every driver (dateValues.js).
|
|
59
|
+
var dateValues = require('./dateValues');
|
|
72
60
|
|
|
73
61
|
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
74
62
|
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
@@ -92,7 +80,12 @@ async function connect(config) {
|
|
|
92
80
|
// batch and has no session-level read-only mode, so SQL the caller did
|
|
93
81
|
// not write needs a login that can only read (db_datareader). See README.
|
|
94
82
|
config.readOnly ? { readOnlyIntent: true } : {},
|
|
95
|
-
config.options || {}
|
|
83
|
+
config.options || {},
|
|
84
|
+
// PINNED, after the caller's options: a zoneless datetime/date comes
|
|
85
|
+
// back with its digits in the Date's UTC fields, whatever zone this
|
|
86
|
+
// process is in. It was the library's default, which a default change
|
|
87
|
+
// or a caller's `useUTC: false` would have silently undone.
|
|
88
|
+
{ useUTC: true }
|
|
96
89
|
)
|
|
97
90
|
});
|
|
98
91
|
await pool.connect();
|
|
@@ -265,7 +258,7 @@ function coerceValue(v, type) {
|
|
|
265
258
|
|
|
266
259
|
switch (type) {
|
|
267
260
|
case 'string':
|
|
268
|
-
if (v instanceof Date) return
|
|
261
|
+
if (v instanceof Date) return dateValues.toInstantUtcClock(v);
|
|
269
262
|
if (Array.isArray(v)) return JSON.stringify(v);
|
|
270
263
|
if (typeof v === 'object') return JSON.stringify(v);
|
|
271
264
|
return String(v);
|
|
@@ -289,10 +282,13 @@ function coerceValue(v, type) {
|
|
|
289
282
|
return v; // let SQL Server reject
|
|
290
283
|
|
|
291
284
|
case 'date':
|
|
292
|
-
return toDateOnly(v);
|
|
285
|
+
return dateValues.toDateOnly(v);
|
|
293
286
|
|
|
294
287
|
case 'datetime':
|
|
295
|
-
return
|
|
288
|
+
return dateValues.toInstantUtcClock(v);
|
|
289
|
+
|
|
290
|
+
case 'localdatetime':
|
|
291
|
+
return dateValues.toLocalDateTime(v);
|
|
296
292
|
|
|
297
293
|
case 'object':
|
|
298
294
|
case 'array':
|
|
@@ -304,13 +300,6 @@ function coerceValue(v, type) {
|
|
|
304
300
|
}
|
|
305
301
|
}
|
|
306
302
|
|
|
307
|
-
// 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — DATETIME2 parses this unambiguously.
|
|
308
|
-
// Unparseable input passes through so SQL Server rejects it (→ error table).
|
|
309
|
-
function toMssqlDatetime(v) {
|
|
310
|
-
var d = (v instanceof Date) ? v : new Date(v);
|
|
311
|
-
if (isNaN(d.getTime())) return v;
|
|
312
|
-
return d.toISOString().slice(0, 23).replace('T', ' ');
|
|
313
|
-
}
|
|
314
303
|
|
|
315
304
|
async function getTableSchema(pool, tableName, schema) {
|
|
316
305
|
var r = await query(pool,
|