@xeplr/actions 1.1.20 → 1.1.22
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 +11 -3
- package/lib/builtins/file/upload.js +18 -1
- package/lib/drivers/db/duckdb.js +7 -0
- package/lib/drivers/db/errorRows.js +37 -0
- package/lib/drivers/db/mssql.js +7 -0
- package/lib/drivers/db/mysql.js +7 -0
- package/lib/drivers/db/postgres.js +7 -0
- package/lib/formats/_xlsx-stream.js +14 -6
- package/lib/formats/index.js +20 -1
- package/lib/formats/pastedRows.js +146 -0
- package/lib/uploader/index.js +80 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -283,7 +283,7 @@ await actions.uploader.upload({
|
|
|
283
283
|
signal, // optional AbortSignal: stop on request
|
|
284
284
|
staged // optional: see Staged loads
|
|
285
285
|
})
|
|
286
|
-
// → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, merged, durationMs }
|
|
286
|
+
// → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, errorRows, aborted, merged, durationMs }
|
|
287
287
|
```
|
|
288
288
|
|
|
289
289
|
`onProgress({ rowsRead, batches, rowsWritten, phase? })` fires after each batch is read, with `rowsWritten` from the write queue's own count, so a caller can show both and see whether writing keeps up. After the read, while the queue drains, it fires again each time the written count moves (`phase: 'writing'`), never on a bare timer, so a stuck write goes quiet.
|
|
@@ -292,6 +292,8 @@ await actions.uploader.upload({
|
|
|
292
292
|
|
|
293
293
|
Rows are spooled to NDJSON files on disk between source and database. From the first batch it creates, if missing: the target table, `<targetTable>_import_errors` (`movement_id`, `row_num`, `error_description`, `underlying_sql`, `raw_row`, `recorded_at`), a `__xeplr_movement_id__` column, and a unique index on `primaryKeys` (not on DuckDB: its upserts are staged and keep none). Server dialects also add `__xeplr_id__`; DuckDB does not.
|
|
294
294
|
|
|
295
|
+
**Rows the database refuses are kept, in `<targetTable>_import_errors`.** When a batch fails, the queue splits it until it finds the rows the database will not take; each one is written to the error table with `row_num` (its 1-based place in the source, so the first data row is 1), `error_description` (the database's own message and code, e.g. `… violates check constraint "t_qty_check" [23514]`), the statement, and `raw_row` (the row as JSON, every column). They load nowhere else, so this table is the only way to see, fix and re-load them. `errorRows` in the result says how many were recorded: `{ recorded, notRecorded, reason? }`. Recording is best-effort and never fails the movement; `notRecorded` counts rows that could not be written (with the first `reason`). Rows dropped because the movement gave up (stopped, aborted) are counted in `dropped` but were never refused, so they are not recorded. Rolling a movement back deletes its error rows too. A queue's own `onErrorTable` hook still runs first. The statement is built once for every dialect (`lib/drivers/db/errorRows.js`), exposed per driver as `buildInsertErrorRowsSql(table, movementId, entries)`.
|
|
296
|
+
|
|
295
297
|
Logical types and what each driver creates:
|
|
296
298
|
|
|
297
299
|
| type | postgres | mysql | mssql | duckdb |
|
|
@@ -348,10 +350,16 @@ Upserts need no index: they are staged and match keys in the move. A read-only o
|
|
|
348
350
|
| format | reads | notes |
|
|
349
351
|
|---|---|---|
|
|
350
352
|
| `csv` | stream | `formatConfig`: `columns` (default first row), `delimiter` (`,`), `cast` (true), `skipEmptyLines`, `trim`. |
|
|
351
|
-
| `excel` | file path | `.xlsx` via `unzipper` + `saxes`; `sheet`, `startRow`. Date-styled cells arrive as `Date`. |
|
|
353
|
+
| `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. |
|
|
352
354
|
| `json` | stream | A JSON array or NDJSON; buffers the whole file. |
|
|
353
355
|
| `txt` | — | Placeholder, not implemented. |
|
|
354
356
|
|
|
357
|
+
**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.
|
|
358
|
+
- `formats.scanPastedRows(format, filePath, { sheet })` → `{ cells, rows, examples: [{ row, column, rows, first }] }` — found, nothing changed. For an import's inspect step.
|
|
359
|
+
- `formatConfig.repairPastedRows: true` on `file-upload` puts them back: the cell splits on its line breaks into the rest of its own row, whole rows, and the start of the last row, whose remaining values are the merged row's later cells. The eaten quotes go back where they opened and closed. The action's output says what it did: `repairedPastedRows: { cells, rows, at }`.
|
|
360
|
+
- **Only an exact shape is repaired:** the pieces must have the sheet's column counts (and the cells the last row's values would leave empty must be empty). Ordinary multi-line text never matches, so a genuine note with line breaks is never touched. On the client's real file: 11 of 11 cells, 465 of 465 rows, no recovered entry number duplicating an existing one.
|
|
361
|
+
- `pastedRows.decodeOoxml`, `splitPastedRows(rec, headers)`, `expandPastedRows(rows)`, `scanForPastedRows(rows)` for other readers.
|
|
362
|
+
|
|
355
363
|
File sources: only `local` is implemented (`path`). `sftp`, `sharepoint` and `google` are placeholders.
|
|
356
364
|
|
|
357
365
|
## Movement metadata (`xeplr_configs`)
|
|
@@ -448,7 +456,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
448
456
|
|
|
449
457
|
| needs nothing | needs a database |
|
|
450
458
|
|---|---|
|
|
451
|
-
| `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root,
|
|
459
|
+
| `test/formats/pasted-rows.test.js` (its last case loads into Postgres when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, else skipped), `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, which needs `@xeplr/db`: a peer dependency, installed for development) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js` (also honours `PG_HOST`/`PG_PORT`/`PG_USER`), `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
452
460
|
| `test/list-columns-length.test.js` (`XEPLR_TEST_PG_URL`, `MYSQL_TEST_URL`, `MSSQL_TEST_URL`; each skipped when unset), `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/duckdb-index-guard.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
|
|
453
461
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
454
462
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
var { upload } = require('../../uploader');
|
|
12
12
|
var fileSources = require('../../drivers/file');
|
|
13
13
|
var formats = require('../../formats');
|
|
14
|
+
var { expandPastedRows } = require('../../formats/pastedRows');
|
|
14
15
|
var dbDrivers = require('../../drivers/db');
|
|
15
16
|
var { SqlQueue } = require('@xeplr/utils/lib/queue');
|
|
16
17
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
@@ -82,6 +83,13 @@ module.exports = {
|
|
|
82
83
|
var stream = await source.openStream(sourceCfg);
|
|
83
84
|
rowsIterable = format.parseStream(stream, input.formatConfig || {});
|
|
84
85
|
}
|
|
86
|
+
// Rows that a paste folded into one cell, put back — only when asked
|
|
87
|
+
// (formatConfig.repairPastedRows), after the person saw what was found.
|
|
88
|
+
// See lib/formats/pastedRows.js.
|
|
89
|
+
var repaired = [];
|
|
90
|
+
if (input.formatConfig && input.formatConfig.repairPastedRows) {
|
|
91
|
+
rowsIterable = expandPastedRows(rowsIterable, function(r) { repaired.push(r); });
|
|
92
|
+
}
|
|
85
93
|
|
|
86
94
|
// Set up target
|
|
87
95
|
var movementId = input.movementId || system.occurrenceId || ('mv_' + generateId());
|
|
@@ -97,7 +105,7 @@ module.exports = {
|
|
|
97
105
|
});
|
|
98
106
|
|
|
99
107
|
try {
|
|
100
|
-
|
|
108
|
+
var result = await upload({
|
|
101
109
|
source: rowsIterable,
|
|
102
110
|
driver: driver,
|
|
103
111
|
connection: pool,
|
|
@@ -120,6 +128,15 @@ module.exports = {
|
|
|
120
128
|
onProgress: system.onProgress || null,
|
|
121
129
|
signal: system.signal || null
|
|
122
130
|
});
|
|
131
|
+
// What the repair put back, for the import's report.
|
|
132
|
+
if (repaired.length) {
|
|
133
|
+
result = Object.assign({}, result, { repairedPastedRows: {
|
|
134
|
+
cells: repaired.length,
|
|
135
|
+
rows: repaired.reduce(function(n, r) { return n + r.rows; }, 0),
|
|
136
|
+
at: repaired
|
|
137
|
+
} });
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
123
140
|
} finally {
|
|
124
141
|
queue.stop();
|
|
125
142
|
await driver.close(pool);
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -966,6 +966,10 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
|
966
966
|
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' SET DATA TYPE TIMESTAMP';
|
|
967
967
|
}
|
|
968
968
|
|
|
969
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
970
|
+
var errorRows = require('./errorRows');
|
|
971
|
+
var placeholders = require('./procedure').placeholders;
|
|
972
|
+
|
|
969
973
|
module.exports = {
|
|
970
974
|
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
971
975
|
requires: ['@duckdb/node-api'],
|
|
@@ -979,6 +983,9 @@ module.exports = {
|
|
|
979
983
|
quoteIdent: quoteIdent,
|
|
980
984
|
buildCreateTableSql: buildCreateTableSql,
|
|
981
985
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
986
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
987
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
|
|
988
|
+
},
|
|
982
989
|
buildUpsertIndexSql: buildUpsertIndexSql,
|
|
983
990
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
984
991
|
buildInsertSql: buildInsertSql,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// ROWS THE DATABASE REFUSED, written to `<table>_import_errors`.
|
|
2
|
+
//
|
|
3
|
+
// Every dialect creates that table (buildCreateErrorTableSql) with the same
|
|
4
|
+
// columns; only the quoting and the bind marks differ. So the statement is
|
|
5
|
+
// built once, here, and each driver passes its own quoteIdent and mark.
|
|
6
|
+
|
|
7
|
+
var COLUMNS = ['movement_id', 'row_num', 'error_description', 'underlying_sql', 'raw_row'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One INSERT for several refused rows.
|
|
11
|
+
*
|
|
12
|
+
* @param {function} quoteIdent the driver's identifier quoting
|
|
13
|
+
* @param {function} mark bind mark for param n (0-based): '?', '$1', '@p0'
|
|
14
|
+
* @param {string} tableName the TARGET table — the error table is named from it
|
|
15
|
+
* @param {string} movementId
|
|
16
|
+
* @param {Array<{rowNum, error, sql, row}>} entries
|
|
17
|
+
* @returns {{ sql: string, params: any[] }}
|
|
18
|
+
*/
|
|
19
|
+
function buildInsertErrorRowsSql(quoteIdent, mark, tableName, movementId, entries) {
|
|
20
|
+
var params = [];
|
|
21
|
+
var groups = entries.map(function(e) {
|
|
22
|
+
var marks = [];
|
|
23
|
+
[movementId, e.rowNum == null ? null : e.rowNum, e.error || null, e.sql || null,
|
|
24
|
+
e.row == null ? null : JSON.stringify(e.row)].forEach(function(v) {
|
|
25
|
+
marks.push(mark(params.length));
|
|
26
|
+
params.push(v);
|
|
27
|
+
});
|
|
28
|
+
return '(' + marks.join(', ') + ')';
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
sql: 'INSERT INTO ' + quoteIdent(tableName + '_import_errors') +
|
|
32
|
+
' (' + COLUMNS.map(quoteIdent).join(', ') + ') VALUES ' + groups.join(', '),
|
|
33
|
+
params: params
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { buildInsertErrorRowsSql: buildInsertErrorRowsSql };
|
package/lib/drivers/db/mssql.js
CHANGED
|
@@ -450,6 +450,10 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
450
450
|
return result;
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
454
|
+
var errorRows = require('./errorRows');
|
|
455
|
+
var placeholders = require('./procedure').placeholders;
|
|
456
|
+
|
|
453
457
|
module.exports = {
|
|
454
458
|
requires: ['mssql'],
|
|
455
459
|
maxParams: MAX_PARAMS,
|
|
@@ -465,6 +469,9 @@ module.exports = {
|
|
|
465
469
|
quoteIdent: quoteIdent,
|
|
466
470
|
buildCreateTableSql: buildCreateTableSql,
|
|
467
471
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
472
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
473
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mssql'), tableName, movementId, entries);
|
|
474
|
+
},
|
|
468
475
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
469
476
|
buildInsertSql: buildInsertSql,
|
|
470
477
|
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -451,6 +451,10 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
451
451
|
return result;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
455
|
+
var errorRows = require('./errorRows');
|
|
456
|
+
var placeholders = require('./procedure').placeholders;
|
|
457
|
+
|
|
454
458
|
module.exports = {
|
|
455
459
|
requires: ['mysql2'],
|
|
456
460
|
maxParams: MAX_PARAMS,
|
|
@@ -466,6 +470,9 @@ module.exports = {
|
|
|
466
470
|
quoteIdent: quoteIdent,
|
|
467
471
|
buildCreateTableSql: buildCreateTableSql,
|
|
468
472
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
473
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
474
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mysql'), tableName, movementId, entries);
|
|
475
|
+
},
|
|
469
476
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
470
477
|
buildInsertSql: buildInsertSql,
|
|
471
478
|
|
|
@@ -403,6 +403,10 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
|
403
403
|
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
407
|
+
var errorRows = require('./errorRows');
|
|
408
|
+
var placeholders = require('./procedure').placeholders;
|
|
409
|
+
|
|
406
410
|
module.exports = {
|
|
407
411
|
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
408
412
|
requires: ['pg'],
|
|
@@ -418,6 +422,9 @@ module.exports = {
|
|
|
418
422
|
quoteIdent: quoteIdent,
|
|
419
423
|
buildCreateTableSql: buildCreateTableSql,
|
|
420
424
|
buildCreateErrorTableSql:buildCreateErrorTableSql,
|
|
425
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
426
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
|
|
427
|
+
},
|
|
421
428
|
buildUpsertIndexSql: buildUpsertIndexSql,
|
|
422
429
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
423
430
|
buildInsertSql: buildInsertSql,
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
var unzipper = require('unzipper');
|
|
29
29
|
var { SaxesParser } = require('saxes');
|
|
30
30
|
var { StringDecoder } = require('string_decoder');
|
|
31
|
+
var { decodeOoxml } = require('./pastedRows');
|
|
31
32
|
|
|
32
33
|
// Strip an XML namespace prefix: "x:row" -> "row", "row" -> "row".
|
|
33
34
|
function localName(n) {
|
|
@@ -194,18 +195,25 @@ async function parseSharedStrings(directory) {
|
|
|
194
195
|
var f = findFile(directory, 'xl/sharedStrings.xml');
|
|
195
196
|
if (!f) return null;
|
|
196
197
|
var strings = [];
|
|
197
|
-
|
|
198
|
+
// inRph: inside a phonetic run (<rPh>, the furigana Japanese Excel stores
|
|
199
|
+
// beside a cell's text). Its <t> is a reading aid, not the cell's value —
|
|
200
|
+
// joining it in gave "東京とうきょう" for 東京.
|
|
201
|
+
var cur = null, inT = 0, inRph = 0, buf = '';
|
|
198
202
|
await saxParseStream(f.stream(), {
|
|
199
203
|
opentag: function(tag) {
|
|
200
204
|
var ln = localName(tag.name);
|
|
201
|
-
if (ln === 'si')
|
|
202
|
-
else if (ln === '
|
|
205
|
+
if (ln === 'si') { cur = ''; }
|
|
206
|
+
else if (ln === 'rPh') { inRph++; }
|
|
207
|
+
else if (ln === 't') { inT++; buf = ''; }
|
|
203
208
|
},
|
|
204
209
|
text: function(t) { if (inT > 0) buf += t; },
|
|
205
210
|
closetag: function(tag) {
|
|
206
211
|
var ln = localName(tag.name);
|
|
207
|
-
if (ln === 't')
|
|
208
|
-
else if (ln === '
|
|
212
|
+
if (ln === 't') { inT--; if (!inRph) cur = (cur || '') + buf; buf = ''; }
|
|
213
|
+
else if (ln === 'rPh') { inRph--; }
|
|
214
|
+
// Excel's own escapes (_x000D_ for a line break inside the cell) decoded
|
|
215
|
+
// here, once per distinct string.
|
|
216
|
+
else if (ln === 'si') { strings.push(decodeOoxml(cur || '')); cur = null; }
|
|
209
217
|
}
|
|
210
218
|
});
|
|
211
219
|
return strings;
|
|
@@ -288,7 +296,7 @@ async function stream_xlsx_sheet_rows(filePath, sheet_name, handlers) {
|
|
|
288
296
|
} else if (curType === 'b') {
|
|
289
297
|
val = textBuf === '1';
|
|
290
298
|
} else if (curType === 'str' || curType === 'inlineStr' || closingLocal === 't') {
|
|
291
|
-
val = textBuf;
|
|
299
|
+
val = decodeOoxml(textBuf);
|
|
292
300
|
} else {
|
|
293
301
|
var n = Number(textBuf);
|
|
294
302
|
val = (textBuf !== '' && !Number.isNaN(n)) ? n : (textBuf === '' ? null : textBuf);
|
package/lib/formats/index.js
CHANGED
|
@@ -33,8 +33,27 @@ function checkFormatRequires(actionName, format, mod) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Which cells of a file hold rows a paste folded into them (pastedRows.js),
|
|
38
|
+
* read without changing anything — for an import's inspect step.
|
|
39
|
+
* opts: the format's own options (`sheet` for excel), plus `examples`.
|
|
40
|
+
*/
|
|
41
|
+
async function scanPastedRows(type, filePath, opts) {
|
|
42
|
+
var format = getFormat(type);
|
|
43
|
+
var config = opts || {};
|
|
44
|
+
var rows = format.inputMode === 'path'
|
|
45
|
+
? format.parsePath(filePath, config)
|
|
46
|
+
: format.parseStream(require('fs').createReadStream(filePath), config);
|
|
47
|
+
return pastedRows.scanForPastedRows(rows, { examples: config.examples });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var pastedRows = require('./pastedRows');
|
|
51
|
+
|
|
36
52
|
module.exports = {
|
|
37
53
|
getFormat: getFormat,
|
|
38
54
|
checkFormatRequires: checkFormatRequires,
|
|
39
|
-
SUPPORTED: Object.keys(FORMATS)
|
|
55
|
+
SUPPORTED: Object.keys(FORMATS),
|
|
56
|
+
scanPastedRows: scanPastedRows,
|
|
57
|
+
// decodeOoxml, splitPastedRows, expandPastedRows, scanForPastedRows
|
|
58
|
+
pastedRows: pastedRows
|
|
40
59
|
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ROWS PASTED INTO ONE CELL — found, and put back.
|
|
2
|
+
//
|
|
3
|
+
// Data copied out of an ERP is tab-separated text, a row per line. Pasted into
|
|
4
|
+
// Excel, a value that STARTS with a double quote (an inch mark: `"4 INCH`)
|
|
5
|
+
// switches Excel into quoted mode, and everything up to the next `"` — tabs,
|
|
6
|
+
// line breaks, whole rows — lands in that one cell. The sheet then holds one
|
|
7
|
+
// row whose cell carries the next N rows, and those rows exist nowhere else.
|
|
8
|
+
// Seen for real: 465 rows folded into 11 Item Description cells.
|
|
9
|
+
//
|
|
10
|
+
// The damage has an exact shape, which is what makes it safe to undo:
|
|
11
|
+
//
|
|
12
|
+
// cell = <its own row's value> \t <its own row's remaining columns>
|
|
13
|
+
// \n <a whole row> \n <a whole row> …
|
|
14
|
+
// \n <the last row's columns up to and including this one>
|
|
15
|
+
//
|
|
16
|
+
// and the merged row's OTHER columns after the cell belong to that last row.
|
|
17
|
+
// So the cell splits on its line breaks into a first piece of (n - c) values,
|
|
18
|
+
// whole rows of n values, and a last piece of L values — n columns, the cell
|
|
19
|
+
// at column c. Usually L is c + 1 (the closing quote was in the same column);
|
|
20
|
+
// when it closed in a later column k, L is k + 1, and the last row's
|
|
21
|
+
// remaining (n - L) values sit in the merged row's cells right after c, the
|
|
22
|
+
// cells beyond them empty. A cell that does not split exactly like that is
|
|
23
|
+
// left alone: ordinary multi-line text never has those counts, so a genuine
|
|
24
|
+
// note with line breaks is never "repaired".
|
|
25
|
+
//
|
|
26
|
+
// The quote that opened the cell and the one that closed it were eaten by the
|
|
27
|
+
// paste; they are put back — at the start of the first row's value, where
|
|
28
|
+
// quoted mode began, and at the end of the last row's value where it ended
|
|
29
|
+
// (an inch mark: `Painting brush 4"`).
|
|
30
|
+
|
|
31
|
+
// Excel's own escape inside cell text: `_xHHHH_` is the character with that
|
|
32
|
+
// hex code. A line break inside a cell is saved as `_x000D_` + a real newline;
|
|
33
|
+
// `_x005F_` is a literal underscore, which is how text that really says
|
|
34
|
+
// "_x000D_" is stored ("_x005F_x000D_") — left to right, that decodes right.
|
|
35
|
+
var ESCAPE = /_x([0-9A-Fa-f]{4})_/g;
|
|
36
|
+
|
|
37
|
+
function decodeOoxml(s) {
|
|
38
|
+
if (typeof s !== 'string' || s.indexOf('_x') === -1) return s;
|
|
39
|
+
return s.replace(ESCAPE, function(_, hex) { return String.fromCharCode(parseInt(hex, 16)); });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
var LINE = /\r\n|\r|\n/;
|
|
43
|
+
var NUMBER = /^-?\d+(\.\d+)?$/;
|
|
44
|
+
|
|
45
|
+
// A value from inside the cell: text, typed as the sheet's own cells would be.
|
|
46
|
+
function cast(v) {
|
|
47
|
+
if (v === '') return null;
|
|
48
|
+
return NUMBER.test(v) ? Number(v) : v;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The rows folded into one of `rec`'s cells, put back — or null when no cell
|
|
53
|
+
* has that exact shape. `headers` in sheet order.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{ column: string, rows: object[] } | null} rows[0] is `rec` itself, rebuilt
|
|
56
|
+
*/
|
|
57
|
+
function splitPastedRows(rec, headers) {
|
|
58
|
+
var n = headers.length;
|
|
59
|
+
for (var c = 0; c < n; c++) {
|
|
60
|
+
var cell = decodeOoxml(rec[headers[c]]);
|
|
61
|
+
if (typeof cell !== 'string' || cell.indexOf('\t') === -1 || !LINE.test(cell)) continue;
|
|
62
|
+
var lines = cell.split(LINE);
|
|
63
|
+
var first = lines[0].split('\t');
|
|
64
|
+
var last = lines[lines.length - 1].split('\t');
|
|
65
|
+
var middle = lines.slice(1, -1).map(function(l) { return l.split('\t'); });
|
|
66
|
+
var L = last.length;
|
|
67
|
+
if (first.length !== n - c || L < c + 1 || L > n) continue;
|
|
68
|
+
if (middle.some(function(m) { return m.length !== n; })) continue;
|
|
69
|
+
// The last row's values after the closing quote: the merged row's cells
|
|
70
|
+
// right after c — and every cell beyond those must be empty, or this is
|
|
71
|
+
// not the shape a paste leaves.
|
|
72
|
+
var tail = headers.slice(c + 1, c + 1 + (n - L));
|
|
73
|
+
var beyond = headers.slice(c + 1 + (n - L));
|
|
74
|
+
if (beyond.some(function(h) { return rec[h] !== null && rec[h] !== undefined && rec[h] !== ''; })) continue;
|
|
75
|
+
|
|
76
|
+
var rows = [];
|
|
77
|
+
var top = {};
|
|
78
|
+
headers.forEach(function(h, i) { top[h] = i < c ? rec[h] : cast(first[i - c]); });
|
|
79
|
+
top[headers[c]] = '"' + first[0];
|
|
80
|
+
rows.push(top);
|
|
81
|
+
middle.forEach(function(m) {
|
|
82
|
+
var r = {};
|
|
83
|
+
headers.forEach(function(h, i) { r[h] = cast(m[i]); });
|
|
84
|
+
rows.push(r);
|
|
85
|
+
});
|
|
86
|
+
var end = {};
|
|
87
|
+
headers.forEach(function(h, i) { end[h] = i < L ? cast(last[i]) : rec[tail[i - L]]; });
|
|
88
|
+
// The closing quote, at the end of the value it closed.
|
|
89
|
+
end[headers[L - 1]] = String(last[L - 1]) + '"';
|
|
90
|
+
rows.push(end);
|
|
91
|
+
return { column: headers[c], rows: rows };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rows as they were before the paste folded them: each record whose cell
|
|
98
|
+
* holds pasted rows becomes those rows. Everything else passes through.
|
|
99
|
+
* `onRepair({ row, column, rows })` hears each one (row: 1-based data row).
|
|
100
|
+
*/
|
|
101
|
+
async function* expandPastedRows(records, onRepair) {
|
|
102
|
+
var headers = null;
|
|
103
|
+
var index = 0;
|
|
104
|
+
for await (var rec of records) {
|
|
105
|
+
index++;
|
|
106
|
+
if (!headers) headers = Object.keys(rec);
|
|
107
|
+
var split = splitPastedRows(rec, headers);
|
|
108
|
+
if (!split) { yield rec; continue; }
|
|
109
|
+
if (onRepair) onRepair({ row: index, column: split.column, rows: split.rows.length - 1 });
|
|
110
|
+
for (var r of split.rows) yield r;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a whole file's rows and say which cells hold pasted rows, without
|
|
116
|
+
* changing anything — for the inspect step, before anything is loaded.
|
|
117
|
+
*
|
|
118
|
+
* @returns {{ cells: number, rows: number, examples: [{ row, column, rows, first }] }}
|
|
119
|
+
* rows: how many rows the repair would recover
|
|
120
|
+
*/
|
|
121
|
+
async function scanForPastedRows(records, opts) {
|
|
122
|
+
var limit = (opts && opts.examples) || 5;
|
|
123
|
+
var headers = null;
|
|
124
|
+
var index = 0;
|
|
125
|
+
var found = { cells: 0, rows: 0, examples: [] };
|
|
126
|
+
for await (var rec of records) {
|
|
127
|
+
index++;
|
|
128
|
+
if (!headers) headers = Object.keys(rec);
|
|
129
|
+
var split = splitPastedRows(rec, headers);
|
|
130
|
+
if (!split) continue;
|
|
131
|
+
found.cells++;
|
|
132
|
+
found.rows += split.rows.length - 1;
|
|
133
|
+
if (found.examples.length < limit) {
|
|
134
|
+
found.examples.push({ row: index, column: split.column, rows: split.rows.length - 1,
|
|
135
|
+
first: String(split.rows[0][split.column]).slice(0, 80) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
decodeOoxml: decodeOoxml,
|
|
143
|
+
splitPastedRows: splitPastedRows,
|
|
144
|
+
expandPastedRows: expandPastedRows,
|
|
145
|
+
scanForPastedRows: scanForPastedRows
|
|
146
|
+
};
|
package/lib/uploader/index.js
CHANGED
|
@@ -96,6 +96,17 @@ async function upload(opts) {
|
|
|
96
96
|
var onStop = opts.signal ? function() { opts.queue.abort(opts.movementId, 'stopped'); } : null;
|
|
97
97
|
if (onStop) opts.signal.addEventListener('abort', onStop, { once: true });
|
|
98
98
|
|
|
99
|
+
// WHERE A REFUSED ROW GOES: `<targetTable>_import_errors`, with the
|
|
100
|
+
// database's reason. The queue splits a failing batch down to the rows the
|
|
101
|
+
// database refuses and hands each one to its onErrorTable hook — whose
|
|
102
|
+
// default does nothing, so every one of them used to vanish, leaving only a
|
|
103
|
+
// count ("63 dropped") and nothing anyone could look at, fix or re-load.
|
|
104
|
+
var refused = recordRefusedRows(opts, driver, pool);
|
|
105
|
+
// Each row's place in the source (1-based), to say WHICH row was refused.
|
|
106
|
+
// Keyed by the row object itself: a split batch slices the same objects, so
|
|
107
|
+
// the number survives however far the queue bisects.
|
|
108
|
+
var rowNumbers = refused.rowNumbers;
|
|
109
|
+
|
|
99
110
|
try {
|
|
100
111
|
var summary = await spool(opts.source, {
|
|
101
112
|
runId: opts.movementId,
|
|
@@ -156,6 +167,9 @@ async function upload(opts) {
|
|
|
156
167
|
onBatch: async function(rows, meta) {
|
|
157
168
|
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
158
169
|
if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
|
|
170
|
+
for (var n = 0; n < rows.length; n++) {
|
|
171
|
+
if (rows[n] && typeof rows[n] === 'object') rowNumbers.set(rows[n], rowsSeen + n + 1);
|
|
172
|
+
}
|
|
159
173
|
for (var off = 0; off < rows.length; off += effectiveBatchSize) {
|
|
160
174
|
var chunk = rows.slice(off, off + effectiveBatchSize);
|
|
161
175
|
// A NAME FOR THIS BATCH, carried all the way to the failure.
|
|
@@ -231,6 +245,7 @@ async function upload(opts) {
|
|
|
231
245
|
} finally {
|
|
232
246
|
if (drainWatch) drainWatch.stop();
|
|
233
247
|
}
|
|
248
|
+
var errorRows = await refused.flush();
|
|
234
249
|
// Stopped while the queue drained: not a finished movement.
|
|
235
250
|
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
236
251
|
|
|
@@ -269,12 +284,17 @@ async function upload(opts) {
|
|
|
269
284
|
totalBatches: totalBatches,
|
|
270
285
|
completed: stats.completed || 0,
|
|
271
286
|
dropped: stats.dropped || 0,
|
|
287
|
+
// Of the dropped rows, how many are in the error table to look at —
|
|
288
|
+
// { recorded, notRecorded, reason? }. Rows dropped without the database
|
|
289
|
+
// refusing them (a movement that gave up) are counted, never recorded.
|
|
290
|
+
errorRows: errorRows,
|
|
272
291
|
aborted: !!stats.aborted,
|
|
273
292
|
// Staged only: what the move put in the target — { inserted, replaced }.
|
|
274
293
|
merged: merged,
|
|
275
294
|
durationMs: Date.now() - startedAt
|
|
276
295
|
};
|
|
277
296
|
} finally {
|
|
297
|
+
refused.detach();
|
|
278
298
|
// A staged load that did not move its rows: stopped, failed, aborted.
|
|
279
299
|
// Its staging table is all it wrote, and dropping it is the whole undo.
|
|
280
300
|
if (staged && !merged && pool) {
|
|
@@ -290,6 +310,66 @@ async function upload(opts) {
|
|
|
290
310
|
}
|
|
291
311
|
}
|
|
292
312
|
|
|
313
|
+
// How many refused rows to write in one INSERT into the error table.
|
|
314
|
+
var ERROR_ROWS_PER_INSERT = 200;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Hook this movement into the queue's onErrorTable, writing every row the
|
|
318
|
+
* database refuses to `<targetTable>_import_errors` — row number, reason,
|
|
319
|
+
* the statement, and the row itself as JSON. Whatever hook the caller gave
|
|
320
|
+
* the queue still runs first. Writing is best-effort: a failure to record is
|
|
321
|
+
* counted and reported, never a failure of the movement.
|
|
322
|
+
*
|
|
323
|
+
* @returns {{ rowNumbers: WeakMap, flush: () => Promise<{recorded, notRecorded, reason?}>, detach: () => void }}
|
|
324
|
+
*/
|
|
325
|
+
function recordRefusedRows(opts, driver, pool) {
|
|
326
|
+
var queue = opts.queue;
|
|
327
|
+
var previous = queue.onErrorTable;
|
|
328
|
+
var rowNumbers = new WeakMap();
|
|
329
|
+
var pending = [];
|
|
330
|
+
var writes = [];
|
|
331
|
+
var tally = { recorded: 0, notRecorded: 0, reason: null };
|
|
332
|
+
var canWrite = typeof driver.buildInsertErrorRowsSql === 'function';
|
|
333
|
+
|
|
334
|
+
function write(entries) {
|
|
335
|
+
if (!canWrite) {
|
|
336
|
+
tally.notRecorded += entries.length;
|
|
337
|
+
tally.reason = tally.reason || 'this database driver cannot write the error table';
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
var built = driver.buildInsertErrorRowsSql(opts.targetTable, opts.movementId, entries);
|
|
341
|
+
writes.push(driver.query(pool, built.sql, built.params).then(
|
|
342
|
+
function() { tally.recorded += entries.length; },
|
|
343
|
+
function(err) { tally.notRecorded += entries.length; tally.reason = tally.reason || err.message; }
|
|
344
|
+
));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
queue.onErrorTable = function(info) {
|
|
348
|
+
try { if (previous) previous.apply(queue, arguments); } catch (_) { /* the caller's hook cannot stop recording */ }
|
|
349
|
+
var item = info && info.item;
|
|
350
|
+
if (!item || item.movementId !== opts.movementId) return;
|
|
351
|
+
var rows = (item.meta && item.meta.rows) || [];
|
|
352
|
+
var reason = (info.error && info.error.message) || info.reason || 'unknown error';
|
|
353
|
+
if (info.errorCode != null) reason = reason + ' [' + info.errorCode + ']';
|
|
354
|
+
rows.forEach(function(row) {
|
|
355
|
+
pending.push({ rowNum: rowNumbers.get(row), error: reason, sql: rows.length === 1 ? item.sql : null, row: row });
|
|
356
|
+
});
|
|
357
|
+
while (pending.length >= ERROR_ROWS_PER_INSERT) write(pending.splice(0, ERROR_ROWS_PER_INSERT));
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
rowNumbers: rowNumbers,
|
|
362
|
+
flush: async function() {
|
|
363
|
+
if (pending.length) write(pending.splice(0));
|
|
364
|
+
await Promise.all(writes);
|
|
365
|
+
var out = { recorded: tally.recorded, notRecorded: tally.notRecorded };
|
|
366
|
+
if (tally.reason) out.reason = tally.reason;
|
|
367
|
+
return out;
|
|
368
|
+
},
|
|
369
|
+
detach: function() { queue.onErrorTable = previous; }
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
293
373
|
/** The error a stopped movement ends with: code 'STOPPED', so a caller can tell it from a failure. */
|
|
294
374
|
function stoppedError() {
|
|
295
375
|
var err = new Error('Stopped by request');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/actions",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.22",
|
|
4
4
|
"description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@duckdb/node-api": "1.5.5-r.3",
|
|
40
|
+
"@xeplr/db": "^1.0.8",
|
|
40
41
|
"@xeplr/email": "^1.0.0",
|
|
41
42
|
"@xeplr/utils": "^1.0.0",
|
|
42
43
|
"csv-parse": "^7.0.2",
|
|
@@ -117,6 +118,7 @@
|
|
|
117
118
|
},
|
|
118
119
|
"devDependencies": {
|
|
119
120
|
"@duckdb/node-api": "^1.5.5-r.3",
|
|
121
|
+
"@xeplr/db": "^1.0.8",
|
|
120
122
|
"@xeplr/utils": "^1.0.0",
|
|
121
123
|
"csv-parse": "^7.0.2",
|
|
122
124
|
"dejavu-fonts-ttf": "^2.37.3",
|