@xeplr/actions 2.2.5 → 2.2.7
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 +54 -5
- package/index.js +5 -1
- package/lib/builtins/db/columns.js +63 -0
- package/lib/builtins/db/fetch.js +47 -6
- package/lib/builtins/db/move.js +3 -60
- package/lib/builtins/db/push.js +19 -8
- package/lib/builtins/export/table.js +6 -4
- package/lib/drivers/db/duckdb.js +3 -0
- package/lib/drivers/db/internalTables.js +15 -0
- package/lib/formats/index.js +2 -1
- package/lib/formats/parquet.js +282 -0
- package/lib/formats/writers/index.js +4 -2
- package/lib/formats/writers/parquet.js +48 -0
- package/lib/uploader/index.js +7 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -80,7 +80,8 @@ var moved = await actions.runAction(actions.builtins.dbMove, {
|
|
|
80
80
|
| `streaming` | `{ spool, resume, readBatchFile, checkpoint }` — rows to rotating NDJSON batch files with ordered hooks and resumable checkpoints. |
|
|
81
81
|
| `drivers.db` | `{ getDriver(type), checkDriverRequires, SUPPORTED }` — `postgres`, `mysql`, `mssql`, `mongo`, `duckdb`. |
|
|
82
82
|
| `drivers.file` | `{ getSource(type), checkSourceRequires, SUPPORTED }` — `local`, `sharepoint`, `google`, `sftp`. |
|
|
83
|
-
| `formats` | `{ getFormat(type), checkFormatRequires, SUPPORTED }` — `excel`, `csv`, `txt`, `json`. |
|
|
83
|
+
| `formats` | `{ getFormat(type), checkFormatRequires, SUPPORTED }` — `excel`, `csv`, `txt`, `json`, `parquet`. |
|
|
84
|
+
| `parquet` | `{ open, writeAll, readRows, columnsOf, isParquet }` — rows to a folder of Parquet parts and back, through DuckDB. See [Parquet](#parquet-the-hand-off-to-python-and-warehouses). |
|
|
84
85
|
| `attachConfig({ service, applicationId? })` | Connect to the shared `xeplr_configs` database; see [Movement metadata](#movement-metadata-xeplr_configs). |
|
|
85
86
|
| `configRequiredEnv` | `['XCFG_DB_NAME']` — spread into your app's required-env list. |
|
|
86
87
|
|
|
@@ -133,10 +134,10 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
133
134
|
| key | action name | does |
|
|
134
135
|
|---|---|---|
|
|
135
136
|
| `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). |
|
|
136
|
-
| `exportTable` | `export-table` | Rows to a **CSV, Excel (.xlsx) or
|
|
137
|
+
| `exportTable` | `export-table` | Rows to a **CSV, Excel (.xlsx), PDF or Parquet** file. See [Export](#export). |
|
|
137
138
|
| `exportPages` | `export-pages` | Pages of placed blocks (text, tables, images, shapes) to a PDF — a dashboard as it looked. See [Export](#export). |
|
|
138
|
-
| `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 }`. |
|
|
139
|
-
| `dbPush` | `db-push` | Write `rows
|
|
139
|
+
| `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 }`; with `format: 'parquet'` a folder of typed Parquet parts `{ filePath, format: 'parquet', parts, bytes, rows }` (see [Parquet](#parquet-the-hand-off-to-python-and-warehouses)); `false` → `{ rows, rowCount }`. |
|
|
140
|
+
| `dbPush` | `db-push` | Write `rows`, or a `filePath` — NDJSON, a `.parquet` file or a folder of Parquet parts — into `targetTable` through the uploader. A Parquet source declares its column types, so the target gets them instead of types guessed from values. `primaryKeys` → upsert. |
|
|
140
141
|
| `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). |
|
|
141
142
|
| `dbDump` | `db-dump` | A whole database to one backup file with its own dump program: `pg_dump -Fc` (Postgres, `.dump`) or `mysqldump` (MySQL, `.sql.gz`). See [Backups](#backups-db-dump-folder-archive-and-s3-upload). |
|
|
142
143
|
| `dbProcedure` | `db-procedure` | Call a stored procedure; returns `{ rows, rowCount, procedure, sql }`. See [db-procedure](#db-procedure). |
|
|
@@ -446,6 +447,7 @@ Decided for the whole step before anything is written, so `fail` never leaves ha
|
|
|
446
447
|
| `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` |
|
|
447
448
|
| `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` |
|
|
448
449
|
| `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` |
|
|
450
|
+
| `parquet` | one `.parquet` file of the raw values, typed (numbers, dates, booleans as themselves), the headers as column names (a repeated header gets ` (2)`). For pandas, DuckDB or a warehouse — see [Parquet](#parquet-the-hand-off-to-python-and-warehouses) | `values` |
|
|
449
451
|
|
|
450
452
|
```js
|
|
451
453
|
await runAction(builtins.exportTable, {
|
|
@@ -479,7 +481,50 @@ await runAction(builtins.exportTable, {
|
|
|
479
481
|
|
|
480
482
|
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.
|
|
481
483
|
|
|
482
|
-
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.
|
|
484
|
+
Peers (optional, needed only by the format used): `fflate` for xlsx; `pdfkit` and `dejavu-fonts-ttf` for pdf; `@duckdb/node-api` for parquet. `writers` (`getWriter`, `INFO`, `SUPPORTED`) is exported for code that wants a writer without the action.
|
|
485
|
+
|
|
486
|
+
## Parquet: the hand-off to Python and warehouses
|
|
487
|
+
|
|
488
|
+
For moving rows between programs — Node to a Python model and back, or into a warehouse — NDJSON is the wrong file: every row repeats every column name, numbers are text, and a date comes back a string the reader has to guess. **Parquet** is stored by column and compressed (typically several times smaller), keeps each column's type, and pandas, pyarrow and DuckDB read it many times faster. It is written **by DuckDB** (already a peer here), not by a JavaScript Parquet library: native, multi-threaded, and correct on dates, timestamps and decimals.
|
|
489
|
+
|
|
490
|
+
**A stream becomes a folder of parts.** A Parquet file cannot be appended to and is unreadable until its last byte (its index is written at the end), so rows stream into a folder:
|
|
491
|
+
|
|
492
|
+
```
|
|
493
|
+
<folder>/part-00001.parquet partRows rows each (default 100,000)
|
|
494
|
+
<folder>/part-00002.parquet
|
|
495
|
+
<folder>/_done written last: { rows, parts }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Rows go into a table in a DuckDB of their own, in memory; at `partRows` it is copied out as the next part and emptied. **Memory stays about one part, whatever the total, and each row is written to disk once.** A part is written under a dot-name and renamed when whole, so a reader never sees half of one. Every reader takes the folder as one table and ignores `_done`:
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
df = pandas.read_parquet('<folder>') # pandas / pyarrow
|
|
502
|
+
duckdb.sql("SELECT * FROM '<folder>/*.parquet'") # DuckDB
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Keep parts large (the default): thousands of tiny parts cost a file open each, which on a network disk (EFS) is a round trip.
|
|
506
|
+
|
|
507
|
+
```js
|
|
508
|
+
var p = actions.parquet;
|
|
509
|
+
var done = await p.writeAll(rows, { dir, columns?, partRows?, signal? });
|
|
510
|
+
// → { path, parts: ['part-00001.parquet', …], rows, bytes, columns }
|
|
511
|
+
for await (var row of p.readRows(done.path)) { … } // a file or a folder
|
|
512
|
+
await p.columnsOf(done.path); // [{ name, type }]
|
|
513
|
+
var w = p.open({ dir }); await w.write(row); …; await w.close(); // or abort()
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
`{ file }` instead of `{ dir }` writes one file at close, its rows held until then — for a bounded export (`export-table`), not a stream.
|
|
517
|
+
|
|
518
|
+
**Types.** `columns: [{ name, type }]` (the logical types every driver speaks) are used as given; otherwise they are inferred from the first `scanRows` (1,000) rows by the uploader's own inference. A value lands as it would in a DuckDB table — the DuckDB driver's type map and coercion are used as they are: `number` → DOUBLE, `datetime` → TIMESTAMPTZ, `date` → DATE, `boolean`, objects and arrays → JSON (read back as their text). `db-fetch` with `mode: 'table'` declares the table's own types, so a Postgres `numeric` (which reaches node as a string) is a number in the file, not text.
|
|
519
|
+
|
|
520
|
+
**Refused, never bent:**
|
|
521
|
+
- A value that does not fit its column fails the write — `Row 6: column "amount" (number) cannot hold "twelve"` (`code: 'PARQUET_ROW'`).
|
|
522
|
+
- A field no earlier row had fails it too: a folder has one set of columns, and dropping a field quietly is worse than stopping. Declare the columns when the first rows do not show them all.
|
|
523
|
+
- A failed or stopped write (`signal` → `code: 'STOPPED'`) removes the parts it wrote and writes no `_done`.
|
|
524
|
+
|
|
525
|
+
Our bookkeeping columns (`__xeplr_id__`, `__xeplr_movement_id__`) never come along, as in every load. No rows with declared columns gives one empty part (a reader still finds the columns); no rows and none declared gives only `_done`, which reads as empty.
|
|
526
|
+
|
|
527
|
+
In a workflow: `db-fetch` (`format: 'parquet'`) → the step that reads it → `db-push` (`filePath`: the folder). `export-table` with `format: 'parquet'` writes one typed file under the headers as column names (made unique).
|
|
483
528
|
|
|
484
529
|
## Uploader
|
|
485
530
|
|
|
@@ -569,6 +614,8 @@ On Postgres, MySQL and SQL Server the staging table is shaped like the target (`
|
|
|
569
614
|
|
|
570
615
|
Beside the tables it loads, the uploader makes three of its own: `<table>_import_errors` (refused rows), `<table>__xeplr_staging` (a staged load in progress) and, in a DuckDB file, `__xeplr_loads` (its ledger). `isInternalTable(name)` (also accepts a listing row `{ name }`) says whether a table is one of these, so an app listing a database's tables for people to pick from can leave them out. They still exist and are read where they belong.
|
|
571
616
|
|
|
617
|
+
It also adds its own columns to every table it loads: `__xeplr_id__` (Postgres, MySQL, SQL Server) and `__xeplr_movement_id__` (every target), the row's id and the load that wrote it. `isInternalColumn(name)` (or `{ name }`) says whether a column is one of these. **They are never carried into another table:** when a loaded table is loaded somewhere else (an import copied into a cube's warehouse, a move between databases), the uploader drops them from the target's columns, whether inferred from the rows or declared by the caller, and the new load stamps its own. Carried along, they clashed with the target's own column (`Column with name __xeplr_movement_id__ already exists`) and would have marked rows with a load that never wrote them.
|
|
618
|
+
|
|
572
619
|
### A written DuckDB file keeps no index
|
|
573
620
|
|
|
574
621
|
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.
|
|
@@ -590,6 +637,7 @@ Upserts need no index: they are staged and match keys in the move. A read-only o
|
|
|
590
637
|
| `csv` | stream | `formatConfig`: `columns` (default first row), `delimiter` (`,`), `cast` (true), `skipEmptyLines`, `trim`. |
|
|
591
638
|
| `excel` | file path | `.xlsx` via `unzipper` + `saxes`; `sheet`, `startRow`. Date-styled cells arrive as `Date`. Excel's own escapes in cell text are decoded (`_x000D_` → a real line break; `_x005F_` → `_`), and Japanese phonetic runs (`<rPh>`, furigana) are not joined into the value. |
|
|
592
639
|
| `json` | stream | A JSON array or NDJSON; buffers the whole file. |
|
|
640
|
+
| `parquet` | file path | A `.parquet` file or a folder of parts, streamed a chunk at a time through DuckDB (`@duckdb/node-api`). Typed: no `formatConfig`. |
|
|
593
641
|
| `txt` | — | Placeholder, not implemented. |
|
|
594
642
|
|
|
595
643
|
**Rows pasted into one cell** (`lib/formats/pastedRows.js`). Data copied out of an ERP and pasted into Excel: a value starting with `"` (an inch mark) makes Excel put everything up to the next `"` — tabs, line breaks, whole rows — into that one cell. The rows it swallowed exist nowhere else. Seen for real: 465 rows folded into 11 cells.
|
|
@@ -733,6 +781,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
733
781
|
- **No `*_HOST`:** that database's tests are **skipped**, and each says which settings run it.
|
|
734
782
|
- **`*_HOST` set:** they run, and a login the database refuses **fails** them. A configured run never passes by not connecting.
|
|
735
783
|
- **A data root and an application per test process** (`--require ./test/setup-data-dir.cjs`, in `npm test`, CI, and the gate through `@flags` on `test/data-dir.test.js`), as an app has at boot. `test/data-dir.test.js` registers tenants: two companies with the same names, each refused the other's files by full path, `../`, a link and a move; `*` and no tenant; email attachments, db-push and a local upload.
|
|
784
|
+
- **Parquet** (`test/formats/parquet.test.js`): parts written and read back by DuckDB's own Parquet reader — 300,000 rows in parts, declared and inferred types, a bad value and a stray column refused, a stop leaving nothing, `export-table` and `file-upload`. `test/builtins/parquet-pg.test.js` takes a real Postgres table through `db-fetch` → Parquet → `db-push` and checks the new table's types and every row.
|
|
736
785
|
- **One file at a time** (`--test-concurrency=1`): the database suites share the `xeplr_actions_test` database and race to create it when run side by side.
|
|
737
786
|
- **Why:** these files used to carry a fixed host, port and password each (Postgres on `:5435`). On any other machine they all failed, so the pre-commit gate and CI ran only `test/*.test.js` and never this package's sub-folders. That is how `data-operation` shipped returning no rows (fixed in 1.4.2).
|
|
738
787
|
|
package/index.js
CHANGED
|
@@ -48,12 +48,16 @@ module.exports = {
|
|
|
48
48
|
uploader: uploader,
|
|
49
49
|
// The uploader's own tables (_import_errors, __xeplr_staging, __xeplr_loads), to leave out of table pickers.
|
|
50
50
|
isInternalTable: require('./lib/drivers/db/internalTables').isInternalTable,
|
|
51
|
+
isInternalColumn: require('./lib/drivers/db/internalTables').isInternalColumn,
|
|
51
52
|
inferColumns: uploader.inferColumns,
|
|
52
53
|
streaming: streaming,
|
|
53
54
|
drivers: { db: dbDrivers, file: fileDrivers },
|
|
54
55
|
formats: formats,
|
|
55
|
-
// { getWriter(type), INFO, SUPPORTED } — csv / xlsx / pdf writers behind export-table.
|
|
56
|
+
// { getWriter(type), INFO, SUPPORTED } — csv / xlsx / pdf / parquet writers behind export-table.
|
|
56
57
|
writers: require('./lib/formats/writers'),
|
|
58
|
+
// { open, writeAll, readRows, columnsOf, isParquet } — rows to a folder of
|
|
59
|
+
// Parquet parts and back, through DuckDB (lib/formats/parquet.js).
|
|
60
|
+
parquet: require('./lib/formats/parquet'),
|
|
57
61
|
|
|
58
62
|
// xeplr_configs — shared control-plane DB (import/movement metadata).
|
|
59
63
|
// One call per app: attachConfig({ service }); await xcfg.ready(). See
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The columns a database source DECLARES — shared by db-move and db-fetch, so
|
|
2
|
+
// both carry a table's own types instead of guessing them from values.
|
|
3
|
+
|
|
4
|
+
function normalizeMapping(columns) {
|
|
5
|
+
return (columns || [])
|
|
6
|
+
.filter(function(c) { return c && c.from; })
|
|
7
|
+
.map(function(c) { return { from: c.from, to: c.to || c.from }; });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The source's DECLARED column types, in the target's names — or null when the
|
|
12
|
+
* source has none to declare.
|
|
13
|
+
*
|
|
14
|
+
* Inference reads values, and for a database source that is throwing away an
|
|
15
|
+
* answer the server already holds. Three ways it goes wrong, all of them quiet
|
|
16
|
+
* and all of them baked into a CREATE TABLE that outlives the sample:
|
|
17
|
+
*
|
|
18
|
+
* Postgres returns `numeric` to node as a STRING. A money column arrives as
|
|
19
|
+
* strings, infers as text, and the target column is created TEXT. You find
|
|
20
|
+
* out when somebody tries to SUM it.
|
|
21
|
+
*
|
|
22
|
+
* A column that is all-null in the first batch has no type, so it becomes
|
|
23
|
+
* text — and stays text once the table exists.
|
|
24
|
+
*
|
|
25
|
+
* A zero-padded code ('007') looks like a number and loses its zeros.
|
|
26
|
+
*
|
|
27
|
+
* Only for mode 'table': a query or a procedure has no single table to
|
|
28
|
+
* describe. Both dialects CAN describe a result set — Postgres by preparing,
|
|
29
|
+
* SQL Server via sp_describe_first_result_set — but that is a per-driver
|
|
30
|
+
* capability that does not exist yet, so those fall back to inference and the
|
|
31
|
+
* caveat above still applies to them.
|
|
32
|
+
*/
|
|
33
|
+
async function declaredColumns(driver, pool, input) {
|
|
34
|
+
var mode = input.mode || (input.table ? 'table' : 'query');
|
|
35
|
+
if (mode !== 'table' || !input.table) return null;
|
|
36
|
+
if (typeof driver.getTableSchema !== 'function' || typeof driver.dataTypeToLogical !== 'function') return null;
|
|
37
|
+
|
|
38
|
+
var schema = await driver.getTableSchema(pool, input.table);
|
|
39
|
+
if (!schema || !schema.length) return null;
|
|
40
|
+
|
|
41
|
+
var byName = {};
|
|
42
|
+
schema.forEach(function(c) { byName[c.name] = c; });
|
|
43
|
+
|
|
44
|
+
var mapping = normalizeMapping(input.columns);
|
|
45
|
+
// No mapping means every source column, unrenamed — so the declaration is
|
|
46
|
+
// the source's own schema in its own order.
|
|
47
|
+
var wanted = mapping.length
|
|
48
|
+
? mapping
|
|
49
|
+
: schema.map(function(c) { return { from: c.name, to: c.name }; });
|
|
50
|
+
|
|
51
|
+
var out = [];
|
|
52
|
+
for (var i = 0; i < wanted.length; i++) {
|
|
53
|
+
var src = byName[wanted[i].from];
|
|
54
|
+
// A mapped column the source does not have is left out rather than
|
|
55
|
+
// guessed at — the SELECT will fail on it anyway, with a better message
|
|
56
|
+
// than anything invented here.
|
|
57
|
+
if (!src) return null;
|
|
58
|
+
out.push({ name: wanted[i].to, type: driver.dataTypeToLogical(src.dataType) });
|
|
59
|
+
}
|
|
60
|
+
return out.length ? out : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { declaredColumns: declaredColumns, normalizeMapping: normalizeMapping };
|
package/lib/builtins/db/fetch.js
CHANGED
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
// Temp file: the tenant's tmp/ under XEPLR_DATA_DIR (lib/dataDir.js),
|
|
18
18
|
// named <occurrenceId>_db-fetch_<seq>.jsonl. Cleanup: none in-action — rely
|
|
19
19
|
// on the sweep-temp-files action (or db-push after it consumes the file).
|
|
20
|
+
//
|
|
21
|
+
// format: 'parquet' writes a FOLDER of Parquet parts instead
|
|
22
|
+
// (<occurrenceId>_db-fetch_<seq>/part-00001.parquet …, then _done), typed —
|
|
23
|
+
// see formats/parquet.js. For a hand-off to Python or a warehouse: smaller,
|
|
24
|
+
// typed, and read far faster than NDJSON. mode 'table' declares the table's
|
|
25
|
+
// own column types; a query's are inferred from its first rows. db-push reads
|
|
26
|
+
// the folder back as it is.
|
|
20
27
|
|
|
21
28
|
var dbDrivers = require('../../drivers/db');
|
|
29
|
+
var parquet = require('../../formats/parquet');
|
|
30
|
+
var { declaredColumns } = require('./columns');
|
|
22
31
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
23
32
|
var path = require('path');
|
|
24
33
|
var fs = require('fs');
|
|
@@ -74,7 +83,11 @@ module.exports = {
|
|
|
74
83
|
{ name: 'params', type: 'array', order: 6,
|
|
75
84
|
description: 'Bind params for mode="query".' },
|
|
76
85
|
{ name: 'streaming_mode', type: 'boolean', default: true, order: 7, group: 'Performance',
|
|
77
|
-
description: 'Default TRUE → spool to
|
|
86
|
+
description: 'Default TRUE → spool to a file (see format). false → inline output.rows (small reads only).' },
|
|
87
|
+
{ name: 'format', type: 'string', default: 'jsonl', order: 7, options: ['jsonl', 'parquet'], group: 'Performance',
|
|
88
|
+
description: 'The spooled file: jsonl (one JSON row per line) or parquet (a folder of typed Parquet parts, for Python or a warehouse).' },
|
|
89
|
+
{ name: 'partRows', type: 'number', default: parquet.PART_ROWS, order: 8, group: 'Performance',
|
|
90
|
+
description: 'parquet only: rows per part file.' },
|
|
78
91
|
{ name: 'batchSize', type: 'number', default: 1000, order: 8, group: 'Performance',
|
|
79
92
|
description: 'Rows pulled per cursor round-trip / stream highWaterMark.' }
|
|
80
93
|
],
|
|
@@ -100,7 +113,7 @@ module.exports = {
|
|
|
100
113
|
}
|
|
101
114
|
var output = input.streaming_mode === false
|
|
102
115
|
? { rows: rows, rowCount: rows.length, streaming: false }
|
|
103
|
-
: { filePath: '(a file in this company\'s tmp/, written when it runs)', format:
|
|
116
|
+
: { filePath: '(a file in this company\'s tmp/, written when it runs)', format: formatOf(input), bytes: 0, rows: rows.length, streaming: true };
|
|
104
117
|
return { output: output, rows: rows };
|
|
105
118
|
},
|
|
106
119
|
|
|
@@ -129,7 +142,29 @@ module.exports = {
|
|
|
129
142
|
return { rows: rows, rowCount: rows.length, streaming: false };
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
|
|
145
|
+
if (formatOf(input) === 'parquet') {
|
|
146
|
+
var columns = await declaredColumns(driver, pool, input);
|
|
147
|
+
var folder = await resolveOutputPath(system, '');
|
|
148
|
+
var written;
|
|
149
|
+
try {
|
|
150
|
+
written = await parquet.writeAll(rowStream, {
|
|
151
|
+
dir: folder, columns: columns, partRows: input.partRows, signal: system.signal
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
fs.rmSync(folder, { recursive: true, force: true });
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
filePath: written.path,
|
|
159
|
+
format: 'parquet',
|
|
160
|
+
parts: written.parts.length,
|
|
161
|
+
bytes: written.bytes,
|
|
162
|
+
rows: written.rows,
|
|
163
|
+
streaming: true
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var filePath = await resolveOutputPath(system, '.jsonl');
|
|
133
168
|
var stats = await writeNDJSON(filePath, rowStream);
|
|
134
169
|
return {
|
|
135
170
|
filePath: filePath,
|
|
@@ -160,10 +195,16 @@ function buildFetchSql(driver, input) {
|
|
|
160
195
|
}
|
|
161
196
|
|
|
162
197
|
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
function formatOf(input) {
|
|
199
|
+
var f = String(input.format || 'jsonl').toLowerCase();
|
|
200
|
+
if (f !== 'jsonl' && f !== 'parquet') throw new Error('db-fetch: format must be "jsonl" or "parquet" — got "' + input.format + '"');
|
|
201
|
+
return f;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The tenant's own temporary folder (lib/dataDir.js). No extension: a folder.
|
|
205
|
+
async function resolveOutputPath(system, extension) {
|
|
165
206
|
var dir = require('../../dataDir').scratch();
|
|
166
|
-
var base = (system.occurrenceId || generateId()) + '_db-fetch_' + generateId() +
|
|
207
|
+
var base = (system.occurrenceId || generateId()) + '_db-fetch_' + generateId() + extension;
|
|
167
208
|
return path.join(dir, base);
|
|
168
209
|
}
|
|
169
210
|
// Write an async iterable of rows to an NDJSON file, honoring write
|
package/lib/builtins/db/move.js
CHANGED
|
@@ -55,6 +55,7 @@ var { SqlQueue } = require('@xeplr/utils/lib/queue');
|
|
|
55
55
|
// action — this file used to own that knowledge, which meant a procedure could
|
|
56
56
|
// only be called by moving its output somewhere. See drivers/db/procedure.js.
|
|
57
57
|
var procedureCall = require('../../drivers/db/procedure');
|
|
58
|
+
var { declaredColumns, normalizeMapping } = require('./columns');
|
|
58
59
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
59
60
|
|
|
60
61
|
var WRITE_MODES = ['replace', 'upsert', 'append'];
|
|
@@ -275,7 +276,7 @@ module.exports = {
|
|
|
275
276
|
//
|
|
276
277
|
// Without this upload() infers from the first batch's VALUES, which is
|
|
277
278
|
// correct for a CSV and quietly lossy here — see declaredColumns.
|
|
278
|
-
var declared = await declaredColumns(sourceDriver, sourcePool, input
|
|
279
|
+
var declared = await declaredColumns(sourceDriver, sourcePool, input);
|
|
279
280
|
|
|
280
281
|
// The actual SQL, so a discrepancy is checkable against the source
|
|
281
282
|
// directly rather than trusted on faith — this is what actually ran,
|
|
@@ -457,60 +458,7 @@ function describeWriteMode(writeMode, primaryKeys) {
|
|
|
457
458
|
return writeMode;
|
|
458
459
|
}
|
|
459
460
|
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* The source's DECLARED column types, in the target's names — or null when the
|
|
464
|
-
* source has none to declare.
|
|
465
|
-
*
|
|
466
|
-
* Inference reads values, and for a database source that is throwing away an
|
|
467
|
-
* answer the server already holds. Three ways it goes wrong, all of them quiet
|
|
468
|
-
* and all of them baked into a CREATE TABLE that outlives the sample:
|
|
469
|
-
*
|
|
470
|
-
* Postgres returns `numeric` to node as a STRING. A money column arrives as
|
|
471
|
-
* strings, infers as text, and the target column is created TEXT. You find
|
|
472
|
-
* out when somebody tries to SUM it.
|
|
473
|
-
*
|
|
474
|
-
* A column that is all-null in the first batch has no type, so it becomes
|
|
475
|
-
* text — and stays text once the table exists.
|
|
476
|
-
*
|
|
477
|
-
* A zero-padded code ('007') looks like a number and loses its zeros.
|
|
478
|
-
*
|
|
479
|
-
* Only for mode 'table': a query or a procedure has no single table to
|
|
480
|
-
* describe. Both dialects CAN describe a result set — Postgres by preparing,
|
|
481
|
-
* SQL Server via sp_describe_first_result_set — but that is a per-driver
|
|
482
|
-
* capability that does not exist yet, so those fall back to inference and the
|
|
483
|
-
* caveat above still applies to them.
|
|
484
|
-
*/
|
|
485
|
-
async function declaredColumns(driver, pool, input, plan) {
|
|
486
|
-
var mode = input.mode || (input.table ? 'table' : 'query');
|
|
487
|
-
if (mode !== 'table' || !input.table) return null;
|
|
488
|
-
if (typeof driver.getTableSchema !== 'function' || typeof driver.dataTypeToLogical !== 'function') return null;
|
|
489
|
-
|
|
490
|
-
var schema = await driver.getTableSchema(pool, input.table);
|
|
491
|
-
if (!schema || !schema.length) return null;
|
|
492
|
-
|
|
493
|
-
var byName = {};
|
|
494
|
-
schema.forEach(function(c) { byName[c.name] = c; });
|
|
495
|
-
|
|
496
|
-
var mapping = normalizeMapping(input.columns);
|
|
497
|
-
// No mapping means every source column, unrenamed — so the declaration is
|
|
498
|
-
// the source's own schema in its own order.
|
|
499
|
-
var wanted = mapping.length
|
|
500
|
-
? mapping
|
|
501
|
-
: schema.map(function(c) { return { from: c.name, to: c.name }; });
|
|
502
|
-
|
|
503
|
-
var out = [];
|
|
504
|
-
for (var i = 0; i < wanted.length; i++) {
|
|
505
|
-
var src = byName[wanted[i].from];
|
|
506
|
-
// A mapped column the source does not have is left out rather than
|
|
507
|
-
// guessed at — the SELECT will fail on it anyway, with a better message
|
|
508
|
-
// than anything invented here.
|
|
509
|
-
if (!src) return null;
|
|
510
|
-
out.push({ name: wanted[i].to, type: driver.dataTypeToLogical(src.dataType) });
|
|
511
|
-
}
|
|
512
|
-
return out.length ? out : null;
|
|
513
|
-
}
|
|
461
|
+
// What the columns actually are: see ./columns.js (shared with db-fetch).
|
|
514
462
|
|
|
515
463
|
// ── the source query ─────────────────────────────────────────────────────
|
|
516
464
|
|
|
@@ -664,11 +612,6 @@ function procedurePlan(driver, input, mapping, win) {
|
|
|
664
612
|
// from there rather than kept as a second copy.
|
|
665
613
|
var quoteQualifiedIdent = procedureCall.quoteQualifiedIdent;
|
|
666
614
|
|
|
667
|
-
function normalizeMapping(columns) {
|
|
668
|
-
return (columns || [])
|
|
669
|
-
.filter(function(c) { return c && c.from; })
|
|
670
|
-
.map(function(c) { return { from: c.from, to: c.to || c.from }; });
|
|
671
|
-
}
|
|
672
615
|
|
|
673
616
|
/** Dialect placeholder for bind param n (0-based). */
|
|
674
617
|
function placeholders(dbType) {
|
package/lib/builtins/db/push.js
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Composes: drivers.db.<type> + uploader.upload + SqlQueue
|
|
4
4
|
//
|
|
5
|
-
// Inputs accept EITHER `rows` (inline) OR `filePath
|
|
6
|
-
// — matches spool()'s output shape)
|
|
5
|
+
// Inputs accept EITHER `rows` (inline) OR `filePath`: an NDJSON file (one row
|
|
6
|
+
// per line — matches spool()'s output shape), or Parquet — a .parquet file or
|
|
7
|
+
// a folder of parts, as db-fetch format 'parquet' writes. A Parquet source
|
|
8
|
+
// declares its columns' types, and the target is created with them rather
|
|
9
|
+
// than with types guessed from values. The action creates its own SqlQueue per
|
|
7
10
|
// call and tears it down when done; movement lifecycle is fully self-contained.
|
|
8
11
|
|
|
9
12
|
var { upload } = require('../../uploader');
|
|
10
13
|
var dbDrivers = require('../../drivers/db');
|
|
14
|
+
var parquet = require('../../formats/parquet');
|
|
11
15
|
var { SqlQueue } = require('@xeplr/utils/lib/queue');
|
|
12
16
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
13
17
|
var fs = require('fs');
|
|
@@ -16,7 +20,7 @@ var readline = require('readline');
|
|
|
16
20
|
module.exports = {
|
|
17
21
|
name: 'db-push',
|
|
18
22
|
description: 'Push rows into a target DB table. Supports UPSERT via primaryKeys, ' +
|
|
19
|
-
'NDJSON streaming via filePath, movement-scoped rollback, and error-table ' +
|
|
23
|
+
'NDJSON or Parquet streaming via filePath, movement-scoped rollback, and error-table ' +
|
|
20
24
|
'routing on failure. Routes to the driver named by input.dbType.',
|
|
21
25
|
requires: [], // per-driver requires checked at runtime once dbType is known
|
|
22
26
|
|
|
@@ -41,7 +45,7 @@ module.exports = {
|
|
|
41
45
|
{ name: 'rows', type: 'array', order: 5,
|
|
42
46
|
description: 'Inline rows (mutually exclusive with filePath).' },
|
|
43
47
|
{ name: 'filePath', type: 'string', order: 6,
|
|
44
|
-
description: '
|
|
48
|
+
description: 'An NDJSON file (one row per line), a .parquet file, or a folder of Parquet parts. Use for large uploads.' },
|
|
45
49
|
{ name: 'movementId', type: 'string', order: 7, group: 'Advanced',
|
|
46
50
|
description: 'Correlation key for rollback. Defaults to system.occurrenceId or a fresh id.' },
|
|
47
51
|
{ name: 'batchSize', type: 'number', default: 5000, order: 8, group: 'Performance' },
|
|
@@ -57,6 +61,8 @@ module.exports = {
|
|
|
57
61
|
|
|
58
62
|
var movementId = input.movementId || system.occurrenceId || ('mv_' + generateId());
|
|
59
63
|
var source = resolveSource(input);
|
|
64
|
+
// A Parquet file says what its columns are; nothing is guessed.
|
|
65
|
+
var columns = source.parquet ? await parquet.columnsOf(source.path) : null;
|
|
60
66
|
|
|
61
67
|
var pool = await driver.connect(input.connection);
|
|
62
68
|
var connectionName = input.targetTable;
|
|
@@ -75,7 +81,7 @@ module.exports = {
|
|
|
75
81
|
|
|
76
82
|
try {
|
|
77
83
|
return await upload({
|
|
78
|
-
source: source,
|
|
84
|
+
source: source.rows,
|
|
79
85
|
driver: driver,
|
|
80
86
|
connection: pool,
|
|
81
87
|
targetTable: input.targetTable,
|
|
@@ -84,6 +90,7 @@ module.exports = {
|
|
|
84
90
|
queue: queue,
|
|
85
91
|
connectionName: connectionName,
|
|
86
92
|
dbType: input.dbType, // recorded in import_meta
|
|
93
|
+
columns: columns && columns.length ? columns : undefined,
|
|
87
94
|
// Movement metadata persists to xeplr_config when the framework supplies
|
|
88
95
|
// a meta store (meta-store-knex bound to xeplr_config); else no-op.
|
|
89
96
|
metaStore: ctx.metaStore,
|
|
@@ -96,10 +103,14 @@ module.exports = {
|
|
|
96
103
|
}
|
|
97
104
|
};
|
|
98
105
|
|
|
99
|
-
//
|
|
106
|
+
// { rows: async iterable, parquet?, path? } for whichever input the caller supplied.
|
|
100
107
|
function resolveSource(input) {
|
|
101
|
-
if (Array.isArray(input.rows) && input.rows.length > 0) return asyncFromArray(input.rows);
|
|
102
|
-
if (input.filePath)
|
|
108
|
+
if (Array.isArray(input.rows) && input.rows.length > 0) return { rows: asyncFromArray(input.rows) };
|
|
109
|
+
if (input.filePath) {
|
|
110
|
+
var file = require('../../dataDir').resolve(input.filePath, 'The rows file');
|
|
111
|
+
if (parquet.isParquet(file)) return { rows: parquet.readRows(file), parquet: true, path: file };
|
|
112
|
+
return { rows: asyncFromNDJSONFile(file) };
|
|
113
|
+
}
|
|
103
114
|
throw new Error('db-push: either input.rows (non-empty) or input.filePath is required');
|
|
104
115
|
}
|
|
105
116
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// export-table — rows to a CSV, Excel or
|
|
1
|
+
// export-table — rows to a CSV, Excel, PDF or Parquet file.
|
|
2
2
|
//
|
|
3
3
|
// The rows arrive already DECIDED: which columns, in what order, under what
|
|
4
4
|
// heading, and for each cell both its raw value and the text a reader saw.
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// xlsx the raw values as TYPED cells carrying each column's number format,
|
|
11
11
|
// so the sheet looks like the screen and still sums
|
|
12
12
|
// pdf the display text, exactly as shown
|
|
13
|
+
// parquet the raw values, typed, under the headers as column names — a
|
|
14
|
+
// file for pandas, DuckDB or a warehouse (formats/parquet.js)
|
|
13
15
|
//
|
|
14
16
|
// Rows come inline (`rows`) or from an NDJSON file (`rowsFile`, one row per
|
|
15
17
|
// line — what streaming/spool writes), so a large result never has to be one
|
|
@@ -59,13 +61,13 @@ module.exports = {
|
|
|
59
61
|
name: 'export-table',
|
|
60
62
|
// What it hands on, with example values: it writes, so it is never previewed.
|
|
61
63
|
example: { filePath: 'exports/sales.xlsx', fileName: 'sales.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bytes: 18432, rows: 1420, truncated: false, skipped: false },
|
|
62
|
-
description: 'Write rows to a CSV, Excel (.xlsx) or
|
|
63
|
-
'a PDF shows each cell as it was displayed.',
|
|
64
|
+
description: 'Write rows to a CSV, Excel (.xlsx), PDF or Parquet file. Excel cells stay numbers with their number format; ' +
|
|
65
|
+
'a PDF shows each cell as it was displayed; Parquet keeps each column typed.',
|
|
64
66
|
requires: [],
|
|
65
67
|
|
|
66
68
|
inputSchema: [
|
|
67
69
|
{ name: 'format', type: 'string', required: true, order: 1, enum: writers.SUPPORTED,
|
|
68
|
-
description: 'csv, xlsx or
|
|
70
|
+
description: 'csv, xlsx, pdf or parquet.' },
|
|
69
71
|
{ name: 'document', type: 'object', order: 2,
|
|
70
72
|
description: '{ title?, subtitle?, footer?, columns: [{ header, key?, kind?: number|date|text, numFmt?, align?, width? }] }. ' +
|
|
71
73
|
'numFmt is an Excel number format ("#,##0.00", "0.0%", "mmm yyyy"). key: which field of a plain record ' +
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -1044,6 +1044,9 @@ module.exports = {
|
|
|
1044
1044
|
|
|
1045
1045
|
toPgType: toPgType,
|
|
1046
1046
|
dataTypeToLogical: dataTypeToLogical,
|
|
1047
|
+
// The Parquet writer (formats/parquet.js) appends through it, so a value
|
|
1048
|
+
// reaches a .parquet file exactly as it would reach a DuckDB table.
|
|
1049
|
+
coerceValue: coerceValue,
|
|
1047
1050
|
quoteIdent: quoteIdent,
|
|
1048
1051
|
buildCreateTableSql: buildCreateTableSql,
|
|
1049
1052
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
@@ -13,6 +13,13 @@ var ERRORS_SUFFIX = '_import_errors';
|
|
|
13
13
|
var STAGING_SUFFIX = require('./staging').SUFFIX;
|
|
14
14
|
var LOADS_LEDGER = '__xeplr_loads';
|
|
15
15
|
|
|
16
|
+
// THE COLUMNS IT ADDS TO EVERY TABLE IT LOADS: a row's own id and the
|
|
17
|
+
// movement that wrote it. They describe a load into THAT table, so they are
|
|
18
|
+
// never carried into another one — loading a loaded table elsewhere (an
|
|
19
|
+
// import copied into a cube's warehouse, a move between databases) leaves
|
|
20
|
+
// them behind and the new load stamps its own (uploader/index.js).
|
|
21
|
+
var INTERNAL_COLUMNS = ['__xeplr_id__', '__xeplr_movement_id__'];
|
|
22
|
+
|
|
16
23
|
function endsWith(s, suffix) {
|
|
17
24
|
return s.length > suffix.length && s.slice(-suffix.length) === suffix;
|
|
18
25
|
}
|
|
@@ -23,8 +30,16 @@ function isInternalTable(name) {
|
|
|
23
30
|
return endsWith(s, ERRORS_SUFFIX) || endsWith(s, STAGING_SUFFIX) || s === LOADS_LEDGER;
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/** Whether a column is one the uploader adds itself. A name, or { name }. */
|
|
34
|
+
function isInternalColumn(column) {
|
|
35
|
+
var s = String(column == null ? '' : (typeof column === 'object' ? column.name || '' : column));
|
|
36
|
+
return INTERNAL_COLUMNS.indexOf(s) !== -1;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
module.exports = {
|
|
27
40
|
isInternalTable: isInternalTable,
|
|
41
|
+
isInternalColumn: isInternalColumn,
|
|
42
|
+
INTERNAL_COLUMNS: INTERNAL_COLUMNS,
|
|
28
43
|
ERRORS_SUFFIX: ERRORS_SUFFIX,
|
|
29
44
|
STAGING_SUFFIX: STAGING_SUFFIX,
|
|
30
45
|
LOADS_LEDGER: LOADS_LEDGER
|
package/lib/formats/index.js
CHANGED
|
@@ -7,7 +7,8 @@ var FORMATS = {
|
|
|
7
7
|
excel: function() { return require('./excel'); },
|
|
8
8
|
csv: function() { return require('./csv'); },
|
|
9
9
|
txt: function() { return require('./txt'); },
|
|
10
|
-
json: function() { return require('./json'); }
|
|
10
|
+
json: function() { return require('./json'); },
|
|
11
|
+
parquet: function() { return require('./parquet'); }
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
function getFormat(type) {
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Parquet — read and write, through DuckDB. No JavaScript Parquet library:
|
|
2
|
+
// DuckDB is already a peer here, writes Parquet in native code across
|
|
3
|
+
// threads, and keeps decimals, dates and timestamps the way pandas and
|
|
4
|
+
// pyarrow read them back.
|
|
5
|
+
//
|
|
6
|
+
// ── WRITING: a folder of parts ──────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// A Parquet file cannot be appended to, and its index is written last, so a
|
|
9
|
+
// stream of rows becomes a FOLDER of part files:
|
|
10
|
+
//
|
|
11
|
+
// <dir>/part-00001.parquet each partRows rows (default 100,000)
|
|
12
|
+
// <dir>/part-00002.parquet
|
|
13
|
+
// <dir>/_done written last: every part is complete
|
|
14
|
+
//
|
|
15
|
+
// Rows are appended into a table in a DuckDB of their own, in memory; at
|
|
16
|
+
// partRows the table is copied out as the next part and emptied. Memory stays
|
|
17
|
+
// about one part whatever the total, and every row is written to disk once.
|
|
18
|
+
// A part is written under a dot-name and renamed when complete, so nothing
|
|
19
|
+
// ever reads half a part. pandas (`read_parquet(dir)`), pyarrow and DuckDB
|
|
20
|
+
// (`'<dir>/*.parquet'`) all read the folder as one table and skip `_done`.
|
|
21
|
+
//
|
|
22
|
+
// `file` instead of `dir` writes ONE file at close — for an export somebody
|
|
23
|
+
// downloads. Its rows are held in memory until then, so it is for bounded row
|
|
24
|
+
// counts, not for a stream of unknown size.
|
|
25
|
+
//
|
|
26
|
+
// ── TYPES ───────────────────────────────────────────────────────────────
|
|
27
|
+
//
|
|
28
|
+
// Declared (`columns: [{ name, type }]`, the logical types every driver
|
|
29
|
+
// speaks) or, when not, inferred from the first scanRows rows with the
|
|
30
|
+
// uploader's own inference (uploader/infer.js). The DuckDB driver's type map
|
|
31
|
+
// and value coercion are used as they are, so a value lands in a .parquet
|
|
32
|
+
// file exactly as it would in a DuckDB table: 'number' is DOUBLE, 'datetime'
|
|
33
|
+
// is TIMESTAMPTZ, 'date' DATE, objects and arrays JSON.
|
|
34
|
+
//
|
|
35
|
+
// A value that does not fit its column fails the write naming the row and
|
|
36
|
+
// the column — never stored as something else. A key no earlier row had
|
|
37
|
+
// fails it too: a Parquet folder has one set of columns, and dropping a
|
|
38
|
+
// field quietly is worse than stopping. Declare the columns when the first
|
|
39
|
+
// rows do not show them all. Our own bookkeeping columns (__xeplr_…__) never
|
|
40
|
+
// come along, as in every load.
|
|
41
|
+
//
|
|
42
|
+
// ── READING ─────────────────────────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// readRows(path) streams a file or a folder of parts, a chunk at a time,
|
|
45
|
+
// through the DuckDB driver's own fetchStream — the same values a DuckDB
|
|
46
|
+
// table gives (dates as Date, BIGINT narrowed, a JSON column as its text).
|
|
47
|
+
|
|
48
|
+
var fs = require('fs');
|
|
49
|
+
var path = require('path');
|
|
50
|
+
var duck = require('../drivers/db/duckdb');
|
|
51
|
+
var { inferColumns } = require('../uploader/infer');
|
|
52
|
+
var { isInternalColumn } = require('../drivers/db/internalTables');
|
|
53
|
+
|
|
54
|
+
var PART_ROWS = 100000;
|
|
55
|
+
var SCAN_ROWS = 1000;
|
|
56
|
+
var DONE = '_done';
|
|
57
|
+
var TABLE = 'rows';
|
|
58
|
+
|
|
59
|
+
function literal(p) { return "'" + String(p).replace(/'/g, "''") + "'"; }
|
|
60
|
+
function partName(n) { return 'part-' + String(n).padStart(5, '0') + '.parquet'; }
|
|
61
|
+
|
|
62
|
+
/** The path DuckDB reads: a file as it is, a folder as its parts. */
|
|
63
|
+
function sourceOf(target) {
|
|
64
|
+
return fs.statSync(target).isDirectory() ? path.join(target, '*.parquet') : target;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when `target` is a .parquet file, or a folder of parts — including one
|
|
69
|
+
* this writer finished with no rows (only `_done`), which reads as empty.
|
|
70
|
+
*/
|
|
71
|
+
function isParquet(target) {
|
|
72
|
+
try {
|
|
73
|
+
var st = fs.statSync(target);
|
|
74
|
+
if (st.isFile()) return /\.parquet$/i.test(target);
|
|
75
|
+
return st.isDirectory() && fs.readdirSync(target).some(function(f) { return /\.parquet$/i.test(f) || f === DONE; });
|
|
76
|
+
} catch (_) { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether there is anything to read: a file, or a folder with at least one part. */
|
|
80
|
+
function hasRows(target) {
|
|
81
|
+
var st = fs.statSync(target);
|
|
82
|
+
return st.isFile() || fs.readdirSync(target).some(function(f) { return /\.parquet$/i.test(f); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open a writer.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} opts
|
|
89
|
+
* dir folder to write parts into (created if missing), or
|
|
90
|
+
* file one .parquet file, written at close
|
|
91
|
+
* columns [{ name, type }] — declared; otherwise inferred
|
|
92
|
+
* partRows rows per part (dir only), default 100,000
|
|
93
|
+
* scanRows rows read before inferring types, default 1,000
|
|
94
|
+
* @returns {{ write(row): Promise<void>, close(): Promise<object>, abort(): Promise<void> }}
|
|
95
|
+
* close() → { path, parts: [names], rows, bytes, columns }
|
|
96
|
+
*/
|
|
97
|
+
function open(opts) {
|
|
98
|
+
opts = opts || {};
|
|
99
|
+
if (!opts.dir === !opts.file) throw new Error('parquet: give a dir (parts) or a file — one of them.');
|
|
100
|
+
var single = Boolean(opts.file);
|
|
101
|
+
var target = single ? opts.file : opts.dir;
|
|
102
|
+
fs.mkdirSync(single ? path.dirname(target) : target, { recursive: true });
|
|
103
|
+
var partRows = single ? Infinity : Math.max(1, opts.partRows || PART_ROWS);
|
|
104
|
+
var scanRows = Math.max(1, opts.scanRows || SCAN_ROWS);
|
|
105
|
+
|
|
106
|
+
var instance = null, conn = null, appender = null;
|
|
107
|
+
var columns = null, known = null;
|
|
108
|
+
var buffer = [];
|
|
109
|
+
var rows = 0, inPart = 0;
|
|
110
|
+
var parts = [], written = [];
|
|
111
|
+
var finished = false;
|
|
112
|
+
|
|
113
|
+
async function start() {
|
|
114
|
+
var declared = (opts.columns && opts.columns.length) ? opts.columns : inferColumns(buffer);
|
|
115
|
+
columns = declared.filter(function(c) { return !isInternalColumn(c); })
|
|
116
|
+
.map(function(c) { return { name: c.name, type: c.type || 'string' }; });
|
|
117
|
+
known = new Set(columns.map(function(c) { return c.name; }));
|
|
118
|
+
var held = buffer;
|
|
119
|
+
buffer = null;
|
|
120
|
+
if (!columns.length) return;
|
|
121
|
+
var api = require('@duckdb/node-api');
|
|
122
|
+
instance = await api.DuckDBInstance.create(':memory:', { TimeZone: 'UTC' });
|
|
123
|
+
conn = await instance.connect();
|
|
124
|
+
await conn.run('CREATE TABLE ' + TABLE + ' (' + columns.map(function(c) {
|
|
125
|
+
return duck.quoteIdent(c.name) + ' ' + duck.toPgType(c.type);
|
|
126
|
+
}).join(', ') + ')');
|
|
127
|
+
appender = await conn.createAppender(TABLE);
|
|
128
|
+
for (var i = 0; i < held.length; i++) await append(held[i], rows - held.length + i + 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function refuse(n, message) {
|
|
132
|
+
return Object.assign(new Error('Row ' + n.toLocaleString('en-IN') + ': ' + message), { code: 'PARQUET_ROW' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function append(row, n) {
|
|
136
|
+
for (var k in row) {
|
|
137
|
+
if (!known.has(k) && !isInternalColumn(k)) {
|
|
138
|
+
throw refuse(n, 'it has a column "' + k + '" that the rows before it did not. ' +
|
|
139
|
+
'Declare the columns when the first rows do not show them all.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (var i = 0; i < columns.length; i++) {
|
|
143
|
+
var c = columns[i];
|
|
144
|
+
var v = duck.coerceValue(row[c.name], c.type);
|
|
145
|
+
try {
|
|
146
|
+
if (v === null) appender.appendNull();
|
|
147
|
+
else if (typeof v === 'number' && c.type === 'number') appender.appendDouble(v);
|
|
148
|
+
else if (typeof v === 'boolean' && c.type === 'boolean') appender.appendBoolean(v);
|
|
149
|
+
else appender.appendVarchar(String(v));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw refuse(n, 'column "' + c.name + '" (' + c.type + ') cannot hold ' + JSON.stringify(row[c.name]) + ' — ' + err.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
appender.endRow();
|
|
155
|
+
inPart++;
|
|
156
|
+
if (inPart >= partRows) await flush();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Copy the table out as the next part, under a dot-name until it is whole.
|
|
160
|
+
async function flush() {
|
|
161
|
+
appender.closeSync();
|
|
162
|
+
var name = single ? path.basename(target) : partName(parts.length + 1);
|
|
163
|
+
var dest = single ? target : path.join(target, name);
|
|
164
|
+
var tmp = path.join(path.dirname(dest), '.' + path.basename(dest) + '.tmp');
|
|
165
|
+
written.push(tmp);
|
|
166
|
+
await conn.run('COPY ' + TABLE + ' TO ' + literal(tmp) + ' (FORMAT parquet, COMPRESSION zstd)');
|
|
167
|
+
fs.renameSync(tmp, dest);
|
|
168
|
+
written[written.length - 1] = dest;
|
|
169
|
+
parts.push(name);
|
|
170
|
+
inPart = 0;
|
|
171
|
+
await conn.run('DELETE FROM ' + TABLE);
|
|
172
|
+
appender = await conn.createAppender(TABLE);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function shut() {
|
|
176
|
+
try { if (appender) appender.closeSync(); } catch (_) {}
|
|
177
|
+
try { if (conn) conn.closeSync(); } catch (_) {}
|
|
178
|
+
try { if (instance) instance.closeSync(); } catch (_) {}
|
|
179
|
+
appender = conn = instance = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
write: async function(row) {
|
|
184
|
+
if (finished) throw new Error('parquet: write after close');
|
|
185
|
+
rows++;
|
|
186
|
+
if (buffer) {
|
|
187
|
+
buffer.push(row || {});
|
|
188
|
+
if (buffer.length >= scanRows) await start();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (columns.length) return append(row || {}, rows);
|
|
192
|
+
// No columns: the first rows had none. A later one with fields is refused, never dropped.
|
|
193
|
+
var extra = Object.keys(row || {}).filter(function(k) { return !isInternalColumn(k); });
|
|
194
|
+
if (extra.length) throw refuse(rows, 'it has a column "' + extra[0] + '" that the rows before it did not.');
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
close: async function() {
|
|
198
|
+
if (finished) throw new Error('parquet: closed twice');
|
|
199
|
+
finished = true;
|
|
200
|
+
try {
|
|
201
|
+
if (buffer) await start();
|
|
202
|
+
// One part even when empty, so a reader still finds the columns.
|
|
203
|
+
// With no rows and none declared there are no columns to write.
|
|
204
|
+
if (columns.length && (inPart > 0 || parts.length === 0)) await flush();
|
|
205
|
+
if (!single) fs.writeFileSync(path.join(target, DONE), JSON.stringify({ rows: rows, parts: parts.length }) + '\n');
|
|
206
|
+
} finally {
|
|
207
|
+
shut();
|
|
208
|
+
}
|
|
209
|
+
var bytes = parts.reduce(function(sum, name) {
|
|
210
|
+
return sum + fs.statSync(single ? target : path.join(target, name)).size;
|
|
211
|
+
}, 0);
|
|
212
|
+
return { path: target, parts: parts, rows: rows, bytes: bytes, columns: columns };
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/** Stop and remove what this writer wrote. The folder itself is the caller's. */
|
|
216
|
+
abort: async function() {
|
|
217
|
+
finished = true;
|
|
218
|
+
shut();
|
|
219
|
+
written.forEach(function(f) { try { fs.rmSync(f, { force: true }); } catch (_) {} });
|
|
220
|
+
if (!single) { try { fs.rmSync(path.join(target, DONE), { force: true }); } catch (_) {} }
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Write every row of an (async) iterable; aborts and rethrows on failure. */
|
|
226
|
+
async function writeAll(rowIterable, opts) {
|
|
227
|
+
var w = open(opts);
|
|
228
|
+
try {
|
|
229
|
+
for await (var row of rowIterable) {
|
|
230
|
+
if (opts.signal && opts.signal.aborted) throw Object.assign(new Error('Stopped by request'), { code: 'STOPPED' });
|
|
231
|
+
await w.write(row);
|
|
232
|
+
}
|
|
233
|
+
return await w.close();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
await w.abort();
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Reading goes through a shared in-memory DuckDB: read_parquet keeps no state
|
|
241
|
+
// in it, so callers never meet each other there.
|
|
242
|
+
async function withReader(fn) {
|
|
243
|
+
var pool = await duck.connect({ file: ':memory:', access: 'rw' });
|
|
244
|
+
try { return await fn(pool); } finally { await duck.close(pool); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A file's or a folder's columns, as logical types: [{ name, type }]. */
|
|
248
|
+
async function columnsOf(target) {
|
|
249
|
+
if (!isParquet(target) || !hasRows(target)) return [];
|
|
250
|
+
return withReader(async function(pool) {
|
|
251
|
+
var res = await duck.query(pool, 'DESCRIBE SELECT * FROM read_parquet(' + literal(sourceOf(target)) + ')', []);
|
|
252
|
+
return res.rows.map(function(r) { return { name: r.column_name, type: duck.dataTypeToLogical(r.column_type) }; });
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Stream the rows of a .parquet file or a folder of parts. */
|
|
257
|
+
async function* readRows(target, opts) {
|
|
258
|
+
opts = opts || {};
|
|
259
|
+
if (!isParquet(target)) throw new Error('parquet: "' + target + '" is not a .parquet file or a folder of parts.');
|
|
260
|
+
if (!hasRows(target)) return;
|
|
261
|
+
var pool = await duck.connect({ file: ':memory:', access: 'rw' });
|
|
262
|
+
try {
|
|
263
|
+
yield* duck.fetchStream(pool, { sql: 'SELECT * FROM read_parquet(' + literal(sourceOf(target)) + ')', signal: opts.signal });
|
|
264
|
+
} finally {
|
|
265
|
+
await duck.close(pool);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
// As a source format (formats/index.js): file-upload and columnsOf take a path.
|
|
271
|
+
requires: ['@duckdb/node-api'],
|
|
272
|
+
inputMode: 'path',
|
|
273
|
+
parsePath: function(filePath, opts) { return readRows(filePath, opts); },
|
|
274
|
+
|
|
275
|
+
open: open,
|
|
276
|
+
writeAll: writeAll,
|
|
277
|
+
readRows: readRows,
|
|
278
|
+
columnsOf: columnsOf,
|
|
279
|
+
isParquet: isParquet,
|
|
280
|
+
PART_ROWS: PART_ROWS,
|
|
281
|
+
DONE: DONE
|
|
282
|
+
};
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
var WRITERS = {
|
|
6
6
|
csv: function() { return require('./csv'); },
|
|
7
7
|
xlsx: function() { return require('./xlsx'); },
|
|
8
|
-
pdf: function() { return require('./pdf'); }
|
|
8
|
+
pdf: function() { return require('./pdf'); },
|
|
9
|
+
parquet: function() { return require('./parquet'); }
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
var INFO = {
|
|
12
13
|
csv: { extension: 'csv', contentType: 'text/csv; charset=utf-8' },
|
|
13
14
|
xlsx: { extension: 'xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
|
14
|
-
pdf: { extension: 'pdf', contentType: 'application/pdf' }
|
|
15
|
+
pdf: { extension: 'pdf', contentType: 'application/pdf' },
|
|
16
|
+
parquet: { extension: 'parquet', contentType: 'application/vnd.apache.parquet' }
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
function getWriter(type) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Parquet writer for export-table — one .parquet file, the raw values, typed.
|
|
2
|
+
//
|
|
3
|
+
// Column names are the headers (made unique: Parquet columns need distinct
|
|
4
|
+
// names). Types come from the values themselves (formats/parquet.js), so a
|
|
5
|
+
// number stays a number and a date a date for whatever program reads it.
|
|
6
|
+
// The rows are held until close, in DuckDB's memory: an export is bounded.
|
|
7
|
+
|
|
8
|
+
var parquet = require('../parquet');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
requires: ['@duckdb/node-api'],
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} filePath
|
|
15
|
+
* @param {object} doc - { columns: [{ header }] }
|
|
16
|
+
* @returns {{ writeRow(row): Promise<boolean>, close(): Promise<{ rows, truncated }> }}
|
|
17
|
+
*/
|
|
18
|
+
open: function(filePath, doc) {
|
|
19
|
+
var seen = {};
|
|
20
|
+
var names = (doc.columns || []).map(function(c, i) {
|
|
21
|
+
var base = c.header == null || c.header === '' ? 'column_' + (i + 1) : String(c.header);
|
|
22
|
+
var name = base;
|
|
23
|
+
for (var n = 2; seen[name]; n++) name = base + ' (' + n + ')';
|
|
24
|
+
seen[name] = true;
|
|
25
|
+
return name;
|
|
26
|
+
});
|
|
27
|
+
var w = parquet.open({ file: filePath });
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
writeRow: async function(row) {
|
|
31
|
+
var values = row.values || [];
|
|
32
|
+
var record = {};
|
|
33
|
+
for (var i = 0; i < names.length; i++) record[names[i]] = values[i] === undefined ? null : values[i];
|
|
34
|
+
try { await w.write(record); } catch (err) { await w.abort(); throw err; }
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
close: async function() {
|
|
38
|
+
try {
|
|
39
|
+
var done = await w.close();
|
|
40
|
+
return { rows: done.rows, truncated: false };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
await w.abort();
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
};
|
package/lib/uploader/index.js
CHANGED
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
//
|
|
60
60
|
// See project_xeplr_actions_streaming.md for the full contract.
|
|
61
61
|
|
|
62
|
+
var { isInternalColumn } = require('../drivers/db/internalTables');
|
|
62
63
|
var path = require('path');
|
|
63
64
|
var fsp = require('fs/promises');
|
|
64
65
|
|
|
@@ -149,6 +150,12 @@ async function upload(opts) {
|
|
|
149
150
|
var scanLimit = Math.min(sample.length, opts.firstBatchScanRows);
|
|
150
151
|
declared = inferColumns(sample.slice(0, scanLimit));
|
|
151
152
|
}
|
|
153
|
+
// OUR OWN COLUMNS NEVER COME ALONG. A source that was itself loaded
|
|
154
|
+
// by the uploader (an import's table, a replica) has its own
|
|
155
|
+
// __xeplr_id__ and __xeplr_movement_id__; carried here they clashed
|
|
156
|
+
// with this table's ("already exists") and would have claimed rows
|
|
157
|
+
// for a load that never wrote them. This load stamps its own.
|
|
158
|
+
declared = declared.filter(function(c) { return !isInternalColumn(c); });
|
|
152
159
|
columns = await runBootstrap(driver, pool, opts.targetTable, declared, opts.primaryKeys, opts.staged);
|
|
153
160
|
if (opts.staged) {
|
|
154
161
|
opts.writeTable = await driver.prepareStaging(pool, opts.targetTable);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/actions",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.7",
|
|
4
4
|
"description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"export",
|
|
24
24
|
"csv",
|
|
25
25
|
"xlsx",
|
|
26
|
-
"pdf"
|
|
26
|
+
"pdf",
|
|
27
|
+
"parquet"
|
|
27
28
|
],
|
|
28
29
|
"author": "xeplr",
|
|
29
30
|
"license": "MIT",
|