@xeplr/actions 1.1.21 → 1.1.23
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 +22 -3
- package/lib/builtins/file/upload.js +4 -0
- package/lib/drivers/db/duckdb.js +16 -2
- package/lib/drivers/db/errorRows.js +37 -0
- package/lib/drivers/db/mssql.js +41 -2
- package/lib/drivers/db/mysql.js +38 -2
- package/lib/drivers/db/postgres.js +38 -2
- package/lib/drivers/db/staging.js +70 -0
- package/lib/formats/index.js +22 -0
- package/lib/uploader/index.js +176 -1
- package/lib/uploader/meta-store-knex.js +3 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
117
117
|
| `dbListViews` | `db-list-views` | `{ views }`. |
|
|
118
118
|
| `dbListProcedures` | `db-list-procedures` | `{ procedures }` (functions excluded). |
|
|
119
119
|
| `dbListColumns` | `db-list-columns` | `{ columns }` — `name`, `dataType`, `udtName` and `maxLength` (characters; `-1` is SQL Server `(max)`, `null` when it has none) for one table. |
|
|
120
|
-
| `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
|
|
120
|
+
| `fileUpload` | `file-upload` | Read a file (`sourceType`, `sourcePath`), parse it (`format`), load it into `targetTable` (`dbType`, `dbConnection`) as a New table, Append or Replace (`mode`, see [Import modes](#import-modes)). Passes `system.onProgress` (`{ rowsRead, batches, rowsWritten }`, per batch) and `system.signal` (stop) to the uploader, as `db-move` does. |
|
|
121
121
|
| `sendEmail` | `email-send` | Send one message. See [Email](#email). |
|
|
122
122
|
| `emailRead` | `email-read` | List messages, newest first, with `unread_only`, `limit` (1–1000, default 50), `since`, `from_equals`, `from_contains`, `subject_contains`, `body: 'full' \| 'text' \| 'none'`, optional NDJSON spooling. |
|
|
123
123
|
| `emailMove` | `email-move` | Move a message (`messageId`, `folder`) to `toFolder`. |
|
|
@@ -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 |
|
|
@@ -329,6 +331,23 @@ With `staged: true` a load **never writes into its target while it runs**. Batch
|
|
|
329
331
|
|
|
330
332
|
A driver opts in by implementing `prepareStaging`, `mergeStaged`, `dropStaging` and `committedMovement`; `upload` refuses `staged` for one that does not. A driver that sets `stagesUpserts: true` (DuckDB) has every upsert staged, asked or not.
|
|
331
333
|
|
|
334
|
+
### Import modes
|
|
335
|
+
|
|
336
|
+
`mode` says what the target must be, checked before a row is read:
|
|
337
|
+
|
|
338
|
+
| mode | the table | then |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| `'new'` | must not exist | created from the file. One that exists is refused ("already exists. Choose Append … or Replace …"), nothing written |
|
|
341
|
+
| `'append'` | must exist | the rows are added. A name that matches no table is refused: a typo is not a new table |
|
|
342
|
+
| `'replace'` | must exist | **staged**: the rows go to `<table>__xeplr_staging` while the import runs; when all are in, one transaction empties the table and moves them in. Stopped, failed or killed before that, the table is as it was. `merged` is `{ inserted, replaced }`, `replaced` being the rows it held before. No `primaryKeys` (that is an upsert), no `replaceFrom` |
|
|
343
|
+
| omitted | | created if missing, else appended (as before modes existed) |
|
|
344
|
+
|
|
345
|
+
A refused target throws with `code: 'TARGET_REFUSED'`. The mode is recorded on the import (`import_meta.meta.mode`), and **a Replace cannot be rolled back**: deleting its rows would leave the table empty, not as it was, so `uploader.rollback` refuses it.
|
|
346
|
+
|
|
347
|
+
On Postgres, MySQL and SQL Server the staging table is shaped like the target (`lib/drivers/db/staging.js`; each driver gives only how to create it and how to run a transaction), so a row the target refuses is refused while staged and recorded in the error table like any other. Postgres copies NOT NULL, defaults and CHECKs (`LIKE … INCLUDING DEFAULTS INCLUDING CONSTRAINTS`); MySQL copies everything (`CREATE TABLE … LIKE`); SQL Server copies columns, NULL-ness and identity but not CHECKs (`SELECT TOP 0 * INTO`), so there a row only a CHECK refuses fails the final move, which leaves the table as it was. These three stage only a whole-table Replace (`stagedReplaceOnly`); DuckDB stages upserts and range replaces too.
|
|
348
|
+
|
|
349
|
+
**Before loading: the file against the table.** `uploader.compareToTable({ driver, connection, targetTable, columns })` → `{ exists, matched, extra, missing: [{ name, required }] }`, matched by exact name, the framework's columns left out. `extra` (in the file, not the table) means the import will be refused; `missing` columns are left empty, and `required` ones (no empty value, no default) mean every row will be refused. `formats.columnsOf(format, filePath, { sheet? })` gives a file's columns from its first row. Every driver's `getTableSchema` now says `nullable` and `hasDefault` (an identity or auto-increment counts as a default).
|
|
350
|
+
|
|
332
351
|
### A written DuckDB file keeps no index
|
|
333
352
|
|
|
334
353
|
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.
|
|
@@ -454,7 +473,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
454
473
|
|
|
455
474
|
| needs nothing | needs a database |
|
|
456
475
|
|---|---|
|
|
457
|
-
| `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,
|
|
476
|
+
| `test/import-modes.test.js` (DuckDB in memory; its Postgres cases run when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, its MySQL cases when `MYSQL_HOST`/`MYSQL_PORT`/`MYSQL_USER`/`MYSQL_PASSWORD` are), `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` |
|
|
458
477
|
| `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` |
|
|
459
478
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
460
479
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
|
@@ -43,6 +43,9 @@ module.exports = {
|
|
|
43
43
|
{ name: 'dbConnection', type: 'object', required: true, order: 11 },
|
|
44
44
|
{ name: 'targetTable', type: 'string', required: true, order: 12 },
|
|
45
45
|
{ name: 'primaryKeys', type: 'array', order: 13, group: 'Advanced' },
|
|
46
|
+
{ name: 'mode', type: 'string', order: 14, group: 'Advanced',
|
|
47
|
+
description: "What the target must be: 'new' (refused if it exists), 'append' (it must exist) or 'replace' " +
|
|
48
|
+
"(it must exist; its rows are replaced in one transaction once every row is in). Omitted: created if missing, else appended." },
|
|
46
49
|
|
|
47
50
|
// CONTROL
|
|
48
51
|
{ name: 'movementId', type: 'string', order: 20, group: 'Advanced' },
|
|
@@ -111,6 +114,7 @@ module.exports = {
|
|
|
111
114
|
connection: pool,
|
|
112
115
|
targetTable: input.targetTable,
|
|
113
116
|
primaryKeys: (input.primaryKeys && input.primaryKeys.length) ? input.primaryKeys : null,
|
|
117
|
+
mode: input.mode || null,
|
|
114
118
|
movementId: movementId,
|
|
115
119
|
queue: queue,
|
|
116
120
|
connectionName: input.targetTable,
|
package/lib/drivers/db/duckdb.js
CHANGED
|
@@ -611,11 +611,12 @@ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
|
|
|
611
611
|
// 'public' here returns an empty column list for a table that plainly exists.
|
|
612
612
|
async function getTableSchema(pool, tableName, schema) {
|
|
613
613
|
var r = await query(pool,
|
|
614
|
-
'SELECT column_name, data_type FROM information_schema.columns ' +
|
|
614
|
+
'SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns ' +
|
|
615
615
|
'WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position',
|
|
616
616
|
[schema || 'main', tableName]);
|
|
617
617
|
return r.rows.map(function(row) {
|
|
618
|
-
return { name: row.column_name, dataType: row.data_type, udtName: row.data_type
|
|
618
|
+
return { name: row.column_name, dataType: row.data_type, udtName: row.data_type,
|
|
619
|
+
nullable: row.is_nullable === 'YES', hasDefault: row.column_default != null };
|
|
619
620
|
});
|
|
620
621
|
}
|
|
621
622
|
|
|
@@ -913,6 +914,12 @@ async function mergeStaged(pool, opts) {
|
|
|
913
914
|
// away from it (a DATE read as UTC midnight here, compared at the
|
|
914
915
|
// source's local midnight there) — and a day deleted but never read
|
|
915
916
|
// back is a day lost. Nothing staged: MIN is NULL, nothing is deleted.
|
|
917
|
+
// THE WHOLE TABLE REPLACED: an import in Replace mode. Its rows go and
|
|
918
|
+
// the staged ones take their place, in this same transaction.
|
|
919
|
+
if (opts.replaceAll) {
|
|
920
|
+
var all = await conn.run('DELETE FROM ' + target);
|
|
921
|
+
replaced = Number(all.rowsChanged) || 0;
|
|
922
|
+
}
|
|
916
923
|
if (opts.replaceFrom) {
|
|
917
924
|
var col = quoteIdent(opts.replaceFrom.column);
|
|
918
925
|
var cut = await conn.run('DELETE FROM ' + target + ' WHERE ' + col + ' >= (SELECT MIN(' + col + ') FROM ' + staging + ')');
|
|
@@ -966,6 +973,10 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
|
966
973
|
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' SET DATA TYPE TIMESTAMP';
|
|
967
974
|
}
|
|
968
975
|
|
|
976
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
977
|
+
var errorRows = require('./errorRows');
|
|
978
|
+
var placeholders = require('./procedure').placeholders;
|
|
979
|
+
|
|
969
980
|
module.exports = {
|
|
970
981
|
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
971
982
|
requires: ['@duckdb/node-api'],
|
|
@@ -979,6 +990,9 @@ module.exports = {
|
|
|
979
990
|
quoteIdent: quoteIdent,
|
|
980
991
|
buildCreateTableSql: buildCreateTableSql,
|
|
981
992
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
993
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
994
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
|
|
995
|
+
},
|
|
982
996
|
buildUpsertIndexSql: buildUpsertIndexSql,
|
|
983
997
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
984
998
|
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
|
@@ -303,7 +303,8 @@ function coerceValue(v, type) {
|
|
|
303
303
|
|
|
304
304
|
async function getTableSchema(pool, tableName, schema) {
|
|
305
305
|
var r = await query(pool,
|
|
306
|
-
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
|
|
306
|
+
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, CHARACTER_MAXIMUM_LENGTH AS maxLength, IS_NULLABLE AS isNullable, ' +
|
|
307
|
+
'COLUMN_DEFAULT AS columnDefault, COLUMNPROPERTY(OBJECT_ID(QUOTENAME(TABLE_SCHEMA) + \'.\' + QUOTENAME(TABLE_NAME)), COLUMN_NAME, \'IsIdentity\') AS isIdentity ' +
|
|
307
308
|
'FROM INFORMATION_SCHEMA.COLUMNS ' +
|
|
308
309
|
'WHERE TABLE_NAME = @p0 AND (@p1 IS NULL OR TABLE_SCHEMA = @p1) ' +
|
|
309
310
|
'ORDER BY ORDINAL_POSITION',
|
|
@@ -311,7 +312,8 @@ async function getTableSchema(pool, tableName, schema) {
|
|
|
311
312
|
);
|
|
312
313
|
return r.rows.map(function(row) {
|
|
313
314
|
// maxLength -1 is (max): nvarchar(max), varchar(max), varbinary(max).
|
|
314
|
-
return { name: row.name, dataType: row.dataType, udtName: row.dataType, maxLength: row.maxLength == null ? null : Number(row.maxLength)
|
|
315
|
+
return { name: row.name, dataType: row.dataType, udtName: row.dataType, maxLength: row.maxLength == null ? null : Number(row.maxLength),
|
|
316
|
+
nullable: row.isNullable === 'YES', hasDefault: row.columnDefault != null || Number(row.isIdentity) === 1 };
|
|
315
317
|
});
|
|
316
318
|
}
|
|
317
319
|
|
|
@@ -450,7 +452,41 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
450
452
|
return result;
|
|
451
453
|
}
|
|
452
454
|
|
|
455
|
+
// STAGED REPLACE — see staging.js. SELECT … INTO copies the columns, their
|
|
456
|
+
// NULL-ness and the identity, but not CHECK or DEFAULT constraints: a row only
|
|
457
|
+
// a CHECK refuses fails the final move, which then leaves the target as it was.
|
|
458
|
+
var staged = require('./staging').stagingFor({
|
|
459
|
+
name: 'mssql', quoteIdent: quoteIdent, query: query,
|
|
460
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
461
|
+
createStagingSql: function(t, s) { return ['SELECT TOP 0 * INTO ' + s + ' FROM ' + t]; },
|
|
462
|
+
transaction: async function(pool, fn) {
|
|
463
|
+
var sql = require('mssql');
|
|
464
|
+
var tx = new sql.Transaction(pool);
|
|
465
|
+
await tx.begin();
|
|
466
|
+
try {
|
|
467
|
+
var out = await fn(async function(text) {
|
|
468
|
+
var r = await new sql.Request(tx).query(text);
|
|
469
|
+
return (r.rowsAffected || []).reduce(function(a, b) { return a + b; }, 0);
|
|
470
|
+
});
|
|
471
|
+
await tx.commit();
|
|
472
|
+
return out;
|
|
473
|
+
} catch (err) {
|
|
474
|
+
await tx.rollback().catch(function() {});
|
|
475
|
+
throw err;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
481
|
+
var errorRows = require('./errorRows');
|
|
482
|
+
var placeholders = require('./procedure').placeholders;
|
|
483
|
+
|
|
453
484
|
module.exports = {
|
|
485
|
+
prepareStaging: staged.prepareStaging,
|
|
486
|
+
dropStaging: staged.dropStaging,
|
|
487
|
+
mergeStaged: staged.mergeStaged,
|
|
488
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
489
|
+
stagedReplaceOnly: true,
|
|
454
490
|
requires: ['mssql'],
|
|
455
491
|
maxParams: MAX_PARAMS,
|
|
456
492
|
|
|
@@ -465,6 +501,9 @@ module.exports = {
|
|
|
465
501
|
quoteIdent: quoteIdent,
|
|
466
502
|
buildCreateTableSql: buildCreateTableSql,
|
|
467
503
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
504
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
505
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mssql'), tableName, movementId, entries);
|
|
506
|
+
},
|
|
468
507
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
469
508
|
buildInsertSql: buildInsertSql,
|
|
470
509
|
|
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -302,14 +302,16 @@ function coerceValue(v, type) {
|
|
|
302
302
|
|
|
303
303
|
async function getTableSchema(pool, tableName, schema) {
|
|
304
304
|
var r = await query(pool,
|
|
305
|
-
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName, CHARACTER_MAXIMUM_LENGTH AS maxLength ' +
|
|
305
|
+
'SELECT COLUMN_NAME AS name, DATA_TYPE AS dataType, COLUMN_TYPE AS udtName, CHARACTER_MAXIMUM_LENGTH AS maxLength, ' +
|
|
306
|
+
'IS_NULLABLE AS isNullable, COLUMN_DEFAULT AS columnDefault, EXTRA AS extra ' +
|
|
306
307
|
'FROM information_schema.COLUMNS ' +
|
|
307
308
|
'WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? ' +
|
|
308
309
|
'ORDER BY ORDINAL_POSITION',
|
|
309
310
|
[schema || null, tableName]
|
|
310
311
|
);
|
|
311
312
|
return r.rows.map(function(row) {
|
|
312
|
-
return { name: row.name, dataType: row.dataType, udtName: row.udtName, maxLength: row.maxLength == null ? null : Number(row.maxLength)
|
|
313
|
+
return { name: row.name, dataType: row.dataType, udtName: row.udtName, maxLength: row.maxLength == null ? null : Number(row.maxLength),
|
|
314
|
+
nullable: row.isNullable === 'YES', hasDefault: row.columnDefault != null || /auto_increment|DEFAULT_GENERATED/i.test(row.extra || '') };
|
|
313
315
|
});
|
|
314
316
|
}
|
|
315
317
|
|
|
@@ -451,7 +453,38 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
451
453
|
return result;
|
|
452
454
|
}
|
|
453
455
|
|
|
456
|
+
// STAGED REPLACE — see staging.js. CREATE TABLE … LIKE copies the columns,
|
|
457
|
+
// NOT NULL, defaults, indexes and (8.0.16+) CHECK constraints.
|
|
458
|
+
var staged = require('./staging').stagingFor({
|
|
459
|
+
name: 'mysql', quoteIdent: quoteIdent, query: query,
|
|
460
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
461
|
+
createStagingSql: function(t, s) { return ['CREATE TABLE ' + s + ' LIKE ' + t]; },
|
|
462
|
+
transaction: async function(pool, fn) {
|
|
463
|
+
var conn = await pool.getConnection();
|
|
464
|
+
try {
|
|
465
|
+
await conn.beginTransaction();
|
|
466
|
+
var out = await fn(async function(sql) { var r = await conn.query(sql); return (r[0] && r[0].affectedRows) || 0; });
|
|
467
|
+
await conn.commit();
|
|
468
|
+
return out;
|
|
469
|
+
} catch (err) {
|
|
470
|
+
await conn.rollback().catch(function() {});
|
|
471
|
+
throw err;
|
|
472
|
+
} finally {
|
|
473
|
+
conn.release();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
479
|
+
var errorRows = require('./errorRows');
|
|
480
|
+
var placeholders = require('./procedure').placeholders;
|
|
481
|
+
|
|
454
482
|
module.exports = {
|
|
483
|
+
prepareStaging: staged.prepareStaging,
|
|
484
|
+
dropStaging: staged.dropStaging,
|
|
485
|
+
mergeStaged: staged.mergeStaged,
|
|
486
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
487
|
+
stagedReplaceOnly: true,
|
|
455
488
|
requires: ['mysql2'],
|
|
456
489
|
maxParams: MAX_PARAMS,
|
|
457
490
|
|
|
@@ -466,6 +499,9 @@ module.exports = {
|
|
|
466
499
|
quoteIdent: quoteIdent,
|
|
467
500
|
buildCreateTableSql: buildCreateTableSql,
|
|
468
501
|
buildCreateErrorTableSql: buildCreateErrorTableSql,
|
|
502
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
503
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mysql'), tableName, movementId, entries);
|
|
504
|
+
},
|
|
469
505
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
470
506
|
buildInsertSql: buildInsertSql,
|
|
471
507
|
|
|
@@ -289,7 +289,7 @@ async function ensureUpsertIndex(pool, tableName, primaryKeys) {
|
|
|
289
289
|
// in ordinal-position order. Empty array if the table doesn't exist.
|
|
290
290
|
async function getTableSchema(pool, tableName, schema) {
|
|
291
291
|
var r = await pool.query(
|
|
292
|
-
"SELECT column_name, data_type, udt_name, character_maximum_length " +
|
|
292
|
+
"SELECT column_name, data_type, udt_name, character_maximum_length, is_nullable, column_default, is_identity " +
|
|
293
293
|
"FROM information_schema.columns " +
|
|
294
294
|
"WHERE table_schema = $2 AND table_name = $1 " +
|
|
295
295
|
"ORDER BY ordinal_position",
|
|
@@ -297,7 +297,8 @@ async function getTableSchema(pool, tableName, schema) {
|
|
|
297
297
|
);
|
|
298
298
|
return r.rows.map(function(row) {
|
|
299
299
|
return { name: row.column_name, dataType: row.data_type, udtName: row.udt_name,
|
|
300
|
-
maxLength: row.character_maximum_length == null ? null : Number(row.character_maximum_length)
|
|
300
|
+
maxLength: row.character_maximum_length == null ? null : Number(row.character_maximum_length),
|
|
301
|
+
nullable: row.is_nullable === 'YES', hasDefault: row.column_default != null || row.is_identity === 'YES' };
|
|
301
302
|
});
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -403,7 +404,39 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
|
403
404
|
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
// STAGED REPLACE — see staging.js. LIKE brings NOT NULL, defaults and CHECKs,
|
|
408
|
+
// so a row the target would refuse is refused while it is staged; indexes stay
|
|
409
|
+
// behind (a whole-table replace matches no keys).
|
|
410
|
+
var staged = require('./staging').stagingFor({
|
|
411
|
+
name: 'postgres', quoteIdent: quoteIdent, query: query,
|
|
412
|
+
dropTableSql: function(t) { return 'DROP TABLE IF EXISTS ' + t; },
|
|
413
|
+
createStagingSql: function(t, s) { return ['CREATE TABLE ' + s + ' (LIKE ' + t + ' INCLUDING DEFAULTS INCLUDING CONSTRAINTS)']; },
|
|
414
|
+
transaction: async function(pool, fn) {
|
|
415
|
+
var client = await pool.connect();
|
|
416
|
+
try {
|
|
417
|
+
await client.query('BEGIN');
|
|
418
|
+
var out = await fn(async function(sql) { return (await client.query(sql)).rowCount || 0; });
|
|
419
|
+
await client.query('COMMIT');
|
|
420
|
+
return out;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
await client.query('ROLLBACK').catch(function() {});
|
|
423
|
+
throw err;
|
|
424
|
+
} finally {
|
|
425
|
+
client.release();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Rows the database refused, into <table>_import_errors — one builder for every dialect.
|
|
431
|
+
var errorRows = require('./errorRows');
|
|
432
|
+
var placeholders = require('./procedure').placeholders;
|
|
433
|
+
|
|
406
434
|
module.exports = {
|
|
435
|
+
prepareStaging: staged.prepareStaging,
|
|
436
|
+
dropStaging: staged.dropStaging,
|
|
437
|
+
mergeStaged: staged.mergeStaged,
|
|
438
|
+
// Staged only to replace a whole table (an import's Replace mode).
|
|
439
|
+
stagedReplaceOnly: true,
|
|
407
440
|
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
408
441
|
requires: ['pg'],
|
|
409
442
|
|
|
@@ -418,6 +451,9 @@ module.exports = {
|
|
|
418
451
|
quoteIdent: quoteIdent,
|
|
419
452
|
buildCreateTableSql: buildCreateTableSql,
|
|
420
453
|
buildCreateErrorTableSql:buildCreateErrorTableSql,
|
|
454
|
+
buildInsertErrorRowsSql: function(tableName, movementId, entries) {
|
|
455
|
+
return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
|
|
456
|
+
},
|
|
421
457
|
buildUpsertIndexSql: buildUpsertIndexSql,
|
|
422
458
|
buildAlterTableAddSql: buildAlterTableAddSql,
|
|
423
459
|
buildInsertSql: buildInsertSql,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A STAGED REPLACE on the server databases (Postgres, MySQL, SQL Server).
|
|
2
|
+
//
|
|
3
|
+
// An import that replaces a table must never leave it empty because the
|
|
4
|
+
// import failed. So the rows are written to `<target>__xeplr_staging` while
|
|
5
|
+
// the import runs, and only when every one is in does ONE transaction empty
|
|
6
|
+
// the target and move them across. Stopped, failed or killed before that:
|
|
7
|
+
// the target is exactly as it was, and the staging table is all there is to
|
|
8
|
+
// drop (the next load drops a leftover one anyway).
|
|
9
|
+
//
|
|
10
|
+
// The staging table is shaped like the target, with its NOT NULL and (where
|
|
11
|
+
// the dialect copies them) CHECK constraints — so a row the target would
|
|
12
|
+
// refuse is refused while it is staged, one row at a time, and recorded in
|
|
13
|
+
// `<target>_import_errors` like any other, rather than failing the move.
|
|
14
|
+
//
|
|
15
|
+
// Each driver supplies only what differs: how to make the staging table and
|
|
16
|
+
// how to run statements in one transaction. DuckDB keeps its own (it also
|
|
17
|
+
// upserts and replaces ranges there — see its "staged loads" section).
|
|
18
|
+
|
|
19
|
+
var SUFFIX = '__xeplr_staging';
|
|
20
|
+
var MOVEMENT_COL = '__xeplr_movement_id__';
|
|
21
|
+
|
|
22
|
+
function stagingTableFor(tableName) { return tableName + SUFFIX; }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} dialect - { name, quoteIdent, query(pool, sql), createStagingSql(target, staging) → string[],
|
|
26
|
+
* transaction(pool, fn(run)) where run(sql) → rows affected }
|
|
27
|
+
*/
|
|
28
|
+
function stagingFor(dialect) {
|
|
29
|
+
var q = dialect.quoteIdent;
|
|
30
|
+
|
|
31
|
+
async function prepareStaging(pool, tableName) {
|
|
32
|
+
var staging = stagingTableFor(tableName);
|
|
33
|
+
await dropStaging(pool, tableName);
|
|
34
|
+
var sqls = dialect.createStagingSql(q(tableName), q(staging));
|
|
35
|
+
for (var i = 0; i < sqls.length; i++) await dialect.query(pool, sqls[i]);
|
|
36
|
+
return staging;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function dropStaging(pool, tableName) {
|
|
40
|
+
await dialect.query(pool, dialect.dropTableSql(q(stagingTableFor(tableName))));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replace the target's rows with the staged ones, in one transaction, then
|
|
45
|
+
* drop the staging table. Only a whole-table replace: upserts and range
|
|
46
|
+
* replaces are staged on DuckDB alone.
|
|
47
|
+
*
|
|
48
|
+
* @returns {{ inserted, replaced }} replaced: the rows the target held before
|
|
49
|
+
*/
|
|
50
|
+
async function mergeStaged(pool, opts) {
|
|
51
|
+
if (!opts.replaceAll || (opts.primaryKeys && opts.primaryKeys.length) || opts.replaceFrom) {
|
|
52
|
+
throw new Error(dialect.name + ': a staged load replaces the whole table here — upserts and range replaces are not staged on this database');
|
|
53
|
+
}
|
|
54
|
+
var target = q(opts.targetTable);
|
|
55
|
+
var staging = q(stagingTableFor(opts.targetTable));
|
|
56
|
+
var cols = [MOVEMENT_COL].concat(opts.columns.map(function(c) { return c.name; })).map(q).join(', ');
|
|
57
|
+
var counts = await dialect.transaction(pool, async function(run) {
|
|
58
|
+
var replaced = await run('DELETE FROM ' + target);
|
|
59
|
+
var inserted = await run('INSERT INTO ' + target + ' (' + cols + ') SELECT ' + cols + ' FROM ' + staging);
|
|
60
|
+
return { inserted: inserted, replaced: replaced };
|
|
61
|
+
});
|
|
62
|
+
// After the commit, and allowed to fail: the rows have landed.
|
|
63
|
+
await dropStaging(pool, opts.targetTable).catch(function() {});
|
|
64
|
+
return counts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { prepareStaging: prepareStaging, dropStaging: dropStaging, mergeStaged: mergeStaged };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { stagingFor: stagingFor, stagingTableFor: stagingTableFor, SUFFIX: SUFFIX };
|
package/lib/formats/index.js
CHANGED
|
@@ -47,6 +47,27 @@ async function scanPastedRows(type, filePath, opts) {
|
|
|
47
47
|
return pastedRows.scanForPastedRows(rows, { examples: config.examples });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A file's column names — its header (CSV), one sheet's (Excel), or the first
|
|
52
|
+
* record's keys (JSON) — read from the first row, the rest left unread. What
|
|
53
|
+
* an import compares with an existing table before loading anything.
|
|
54
|
+
* opts: the format's own options (`sheet` for excel).
|
|
55
|
+
*/
|
|
56
|
+
async function columnsOf(type, filePath, opts) {
|
|
57
|
+
var format = getFormat(type);
|
|
58
|
+
var config = opts || {};
|
|
59
|
+
var rows = format.inputMode === 'path'
|
|
60
|
+
? format.parsePath(filePath, config)
|
|
61
|
+
: format.parseStream(require('fs').createReadStream(filePath), config);
|
|
62
|
+
var iterator = rows[Symbol.asyncIterator]();
|
|
63
|
+
try {
|
|
64
|
+
var first = await iterator.next();
|
|
65
|
+
return first.done || !first.value ? [] : Object.keys(first.value);
|
|
66
|
+
} finally {
|
|
67
|
+
if (typeof iterator.return === 'function') await iterator.return();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
var pastedRows = require('./pastedRows');
|
|
51
72
|
|
|
52
73
|
module.exports = {
|
|
@@ -54,6 +75,7 @@ module.exports = {
|
|
|
54
75
|
checkFormatRequires: checkFormatRequires,
|
|
55
76
|
SUPPORTED: Object.keys(FORMATS),
|
|
56
77
|
scanPastedRows: scanPastedRows,
|
|
78
|
+
columnsOf: columnsOf,
|
|
57
79
|
// decodeOoxml, splitPastedRows, expandPastedRows, scanForPastedRows
|
|
58
80
|
pastedRows: pastedRows
|
|
59
81
|
};
|
package/lib/uploader/index.js
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
// keepBatchFiles, // default false — clean up NDJSON files after success
|
|
31
31
|
// replaceFrom, // optional, staged only: { column } — the move deletes target
|
|
32
32
|
// // rows from the earliest staged value on first (see mergeStaged)
|
|
33
|
+
// mode, // optional: 'new' | 'append' | 'replace' — what the target
|
|
34
|
+
// // must be (see MODES). Omitted: create it if missing,
|
|
35
|
+
// // else append, as before modes existed.
|
|
33
36
|
// staged, // default false. true: batches land in <targetTable>__xeplr_staging
|
|
34
37
|
// // and move into the target in ONE transaction at the end, so a
|
|
35
38
|
// // load that stops or dies midway never touched the target.
|
|
@@ -96,7 +99,23 @@ async function upload(opts) {
|
|
|
96
99
|
var onStop = opts.signal ? function() { opts.queue.abort(opts.movementId, 'stopped'); } : null;
|
|
97
100
|
if (onStop) opts.signal.addEventListener('abort', onStop, { once: true });
|
|
98
101
|
|
|
102
|
+
// WHERE A REFUSED ROW GOES: `<targetTable>_import_errors`, with the
|
|
103
|
+
// database's reason. The queue splits a failing batch down to the rows the
|
|
104
|
+
// database refuses and hands each one to its onErrorTable hook — whose
|
|
105
|
+
// default does nothing, so every one of them used to vanish, leaving only a
|
|
106
|
+
// count ("63 dropped") and nothing anyone could look at, fix or re-load.
|
|
107
|
+
var refused = recordRefusedRows(opts, driver, pool);
|
|
108
|
+
// Each row's place in the source (1-based), to say WHICH row was refused.
|
|
109
|
+
// Keyed by the row object itself: a split batch slices the same objects, so
|
|
110
|
+
// the number survives however far the queue bisects.
|
|
111
|
+
var rowNumbers = refused.rowNumbers;
|
|
112
|
+
|
|
99
113
|
try {
|
|
114
|
+
// WHAT THE TARGET MUST BE, checked before a row is read: a New table must
|
|
115
|
+
// not exist, and Append and Replace need one that does — a typo in the
|
|
116
|
+
// name would otherwise make a fresh table and "append" to nothing.
|
|
117
|
+
if (opts.mode) await checkMode(opts, driver, pool);
|
|
118
|
+
|
|
100
119
|
var summary = await spool(opts.source, {
|
|
101
120
|
runId: opts.movementId,
|
|
102
121
|
batchDir: opts.batchDir,
|
|
@@ -143,6 +162,7 @@ async function upload(opts) {
|
|
|
143
162
|
dbType: opts.dbType, // recorded when the caller supplies it
|
|
144
163
|
columns: columns,
|
|
145
164
|
primaryKeys: opts.primaryKeys,
|
|
165
|
+
mode: opts.mode || null,
|
|
146
166
|
mtId1: opts.mtId1, mtId2: opts.mtId2, mtId3: opts.mtId3, mtId4: opts.mtId4,
|
|
147
167
|
details: opts.details
|
|
148
168
|
});
|
|
@@ -156,6 +176,9 @@ async function upload(opts) {
|
|
|
156
176
|
onBatch: async function(rows, meta) {
|
|
157
177
|
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
158
178
|
if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
|
|
179
|
+
for (var n = 0; n < rows.length; n++) {
|
|
180
|
+
if (rows[n] && typeof rows[n] === 'object') rowNumbers.set(rows[n], rowsSeen + n + 1);
|
|
181
|
+
}
|
|
159
182
|
for (var off = 0; off < rows.length; off += effectiveBatchSize) {
|
|
160
183
|
var chunk = rows.slice(off, off + effectiveBatchSize);
|
|
161
184
|
// A NAME FOR THIS BATCH, carried all the way to the failure.
|
|
@@ -231,6 +254,7 @@ async function upload(opts) {
|
|
|
231
254
|
} finally {
|
|
232
255
|
if (drainWatch) drainWatch.stop();
|
|
233
256
|
}
|
|
257
|
+
var errorRows = await refused.flush();
|
|
234
258
|
// Stopped while the queue drained: not a finished movement.
|
|
235
259
|
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
236
260
|
|
|
@@ -250,7 +274,8 @@ async function upload(opts) {
|
|
|
250
274
|
columns: columns,
|
|
251
275
|
primaryKeys: opts.primaryKeys,
|
|
252
276
|
movementId: opts.movementId,
|
|
253
|
-
replaceFrom: opts.replaceFrom
|
|
277
|
+
replaceFrom: opts.replaceFrom,
|
|
278
|
+
replaceAll: opts.replaceAll
|
|
254
279
|
});
|
|
255
280
|
}
|
|
256
281
|
await opts.metaStore.recordEnd(opts.movementId, {
|
|
@@ -269,12 +294,19 @@ async function upload(opts) {
|
|
|
269
294
|
totalBatches: totalBatches,
|
|
270
295
|
completed: stats.completed || 0,
|
|
271
296
|
dropped: stats.dropped || 0,
|
|
297
|
+
// Of the dropped rows, how many are in the error table to look at —
|
|
298
|
+
// { recorded, notRecorded, reason? }. Rows dropped without the database
|
|
299
|
+
// refusing them (a movement that gave up) are counted, never recorded.
|
|
300
|
+
errorRows: errorRows,
|
|
272
301
|
aborted: !!stats.aborted,
|
|
273
302
|
// Staged only: what the move put in the target — { inserted, replaced }.
|
|
303
|
+
// For a Replace, `replaced` is how many rows the table held before.
|
|
274
304
|
merged: merged,
|
|
305
|
+
mode: opts.mode || null,
|
|
275
306
|
durationMs: Date.now() - startedAt
|
|
276
307
|
};
|
|
277
308
|
} finally {
|
|
309
|
+
refused.detach();
|
|
278
310
|
// A staged load that did not move its rows: stopped, failed, aborted.
|
|
279
311
|
// Its staging table is all it wrote, and dropping it is the whole undo.
|
|
280
312
|
if (staged && !merged && pool) {
|
|
@@ -290,6 +322,66 @@ async function upload(opts) {
|
|
|
290
322
|
}
|
|
291
323
|
}
|
|
292
324
|
|
|
325
|
+
// How many refused rows to write in one INSERT into the error table.
|
|
326
|
+
var ERROR_ROWS_PER_INSERT = 200;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Hook this movement into the queue's onErrorTable, writing every row the
|
|
330
|
+
* database refuses to `<targetTable>_import_errors` — row number, reason,
|
|
331
|
+
* the statement, and the row itself as JSON. Whatever hook the caller gave
|
|
332
|
+
* the queue still runs first. Writing is best-effort: a failure to record is
|
|
333
|
+
* counted and reported, never a failure of the movement.
|
|
334
|
+
*
|
|
335
|
+
* @returns {{ rowNumbers: WeakMap, flush: () => Promise<{recorded, notRecorded, reason?}>, detach: () => void }}
|
|
336
|
+
*/
|
|
337
|
+
function recordRefusedRows(opts, driver, pool) {
|
|
338
|
+
var queue = opts.queue;
|
|
339
|
+
var previous = queue.onErrorTable;
|
|
340
|
+
var rowNumbers = new WeakMap();
|
|
341
|
+
var pending = [];
|
|
342
|
+
var writes = [];
|
|
343
|
+
var tally = { recorded: 0, notRecorded: 0, reason: null };
|
|
344
|
+
var canWrite = typeof driver.buildInsertErrorRowsSql === 'function';
|
|
345
|
+
|
|
346
|
+
function write(entries) {
|
|
347
|
+
if (!canWrite) {
|
|
348
|
+
tally.notRecorded += entries.length;
|
|
349
|
+
tally.reason = tally.reason || 'this database driver cannot write the error table';
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
var built = driver.buildInsertErrorRowsSql(opts.targetTable, opts.movementId, entries);
|
|
353
|
+
writes.push(driver.query(pool, built.sql, built.params).then(
|
|
354
|
+
function() { tally.recorded += entries.length; },
|
|
355
|
+
function(err) { tally.notRecorded += entries.length; tally.reason = tally.reason || err.message; }
|
|
356
|
+
));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
queue.onErrorTable = function(info) {
|
|
360
|
+
try { if (previous) previous.apply(queue, arguments); } catch (_) { /* the caller's hook cannot stop recording */ }
|
|
361
|
+
var item = info && info.item;
|
|
362
|
+
if (!item || item.movementId !== opts.movementId) return;
|
|
363
|
+
var rows = (item.meta && item.meta.rows) || [];
|
|
364
|
+
var reason = (info.error && info.error.message) || info.reason || 'unknown error';
|
|
365
|
+
if (info.errorCode != null) reason = reason + ' [' + info.errorCode + ']';
|
|
366
|
+
rows.forEach(function(row) {
|
|
367
|
+
pending.push({ rowNum: rowNumbers.get(row), error: reason, sql: rows.length === 1 ? item.sql : null, row: row });
|
|
368
|
+
});
|
|
369
|
+
while (pending.length >= ERROR_ROWS_PER_INSERT) write(pending.splice(0, ERROR_ROWS_PER_INSERT));
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
rowNumbers: rowNumbers,
|
|
374
|
+
flush: async function() {
|
|
375
|
+
if (pending.length) write(pending.splice(0));
|
|
376
|
+
await Promise.all(writes);
|
|
377
|
+
var out = { recorded: tally.recorded, notRecorded: tally.notRecorded };
|
|
378
|
+
if (tally.reason) out.reason = tally.reason;
|
|
379
|
+
return out;
|
|
380
|
+
},
|
|
381
|
+
detach: function() { queue.onErrorTable = previous; }
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
293
385
|
/** The error a stopped movement ends with: code 'STOPPED', so a caller can tell it from a failure. */
|
|
294
386
|
function stoppedError() {
|
|
295
387
|
var err = new Error('Stopped by request');
|
|
@@ -316,6 +408,13 @@ async function rollback(opts) {
|
|
|
316
408
|
|
|
317
409
|
if (opts.metaStore && opts.metaStore.getById) {
|
|
318
410
|
var meta = await opts.metaStore.getById(opts.movementId);
|
|
411
|
+
// A REPLACE emptied the table before its rows went in. Deleting its rows
|
|
412
|
+
// now would leave the table empty, not as it was — the old rows are gone.
|
|
413
|
+
var metaOf = meta && meta.meta && typeof meta.meta === 'string' ? JSON.parse(meta.meta) : (meta && meta.meta);
|
|
414
|
+
if (metaOf && metaOf.mode === 'replace') {
|
|
415
|
+
throw new Error('rollback: this import replaced the table\'s rows — they cannot be brought back by deleting the new ones, ' +
|
|
416
|
+
'so it cannot be rolled back. Import the earlier data again to restore it.');
|
|
417
|
+
}
|
|
319
418
|
if (meta && meta.primary_keys && meta.primary_keys.length) {
|
|
320
419
|
throw new Error(
|
|
321
420
|
'rollback: this movement used upsert (primary keys: ' + meta.primary_keys.join(', ') + ') — ' +
|
|
@@ -378,6 +477,18 @@ function normalizeOptions(opts) {
|
|
|
378
477
|
if (opts.driver && opts.driver.stagesUpserts && (opts.primaryKeys || []).length) opts.staged = true;
|
|
379
478
|
opts.writeTable = null;
|
|
380
479
|
opts.replaceFrom = opts.replaceFrom || null;
|
|
480
|
+
if (opts.mode != null && MODES.indexOf(opts.mode) === -1) {
|
|
481
|
+
throw new Error('upload: mode must be one of ' + MODES.join(', ') + ' — got "' + opts.mode + '"');
|
|
482
|
+
}
|
|
483
|
+
// REPLACE IS STAGED: the rows go to a staging table while the import runs,
|
|
484
|
+
// and one transaction empties the target and moves them in at the end — so
|
|
485
|
+
// an import that fails or is stopped never leaves the table empty.
|
|
486
|
+
opts.replaceAll = opts.mode === 'replace';
|
|
487
|
+
if (opts.replaceAll) {
|
|
488
|
+
if ((opts.primaryKeys || []).length) throw new Error('upload: Replace empties the table first, so it takes no primaryKeys (that is an upsert)');
|
|
489
|
+
if (opts.replaceFrom) throw new Error('upload: Replace replaces the whole table; replaceFrom replaces a range — choose one');
|
|
490
|
+
opts.staged = true;
|
|
491
|
+
}
|
|
381
492
|
if (opts.replaceFrom) {
|
|
382
493
|
if (!opts.replaceFrom.column) throw new Error('upload: replaceFrom needs a column');
|
|
383
494
|
if ((opts.primaryKeys || []).length) throw new Error('upload: replaceFrom replaces a range, so it takes no primaryKeys');
|
|
@@ -386,9 +497,71 @@ function normalizeOptions(opts) {
|
|
|
386
497
|
if (opts.staged && typeof opts.driver.mergeStaged !== 'function') {
|
|
387
498
|
throw new Error('upload: staged loads are not supported for this database — its driver has no mergeStaged');
|
|
388
499
|
}
|
|
500
|
+
if (opts.staged && opts.driver.stagedReplaceOnly && !opts.replaceAll) {
|
|
501
|
+
throw new Error('upload: on this database a load is staged only to replace a whole table (mode: replace) — not for upserts or replaceFrom');
|
|
502
|
+
}
|
|
389
503
|
return opts;
|
|
390
504
|
}
|
|
391
505
|
|
|
506
|
+
// What an import may do to its target. 'new': make it — refused if it exists.
|
|
507
|
+
// 'append': add to it — it must exist. 'replace': empty it and load — it must
|
|
508
|
+
// exist; staged, so a failed import leaves it as it was.
|
|
509
|
+
var MODES = ['new', 'append', 'replace'];
|
|
510
|
+
|
|
511
|
+
async function checkMode(opts, driver, pool) {
|
|
512
|
+
var schema = await driver.getTableSchema(pool, opts.targetTable);
|
|
513
|
+
var exists = !!(schema && schema.length);
|
|
514
|
+
if (opts.mode === 'new' && exists) {
|
|
515
|
+
throw refusal('Table "' + opts.targetTable + '" already exists. Choose Append to add to it or Replace to replace its rows, or give a new name.');
|
|
516
|
+
}
|
|
517
|
+
if (opts.mode !== 'new' && !exists) {
|
|
518
|
+
throw refusal('Table "' + opts.targetTable + '" does not exist, so there is nothing to ' + (opts.mode === 'append' ? 'append to' : 'replace') +
|
|
519
|
+
'. Check the name, or choose New table.');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function refusal(message) {
|
|
524
|
+
var err = new Error(message);
|
|
525
|
+
err.code = 'TARGET_REFUSED';
|
|
526
|
+
return err;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The file's columns against an existing table — what an import would do,
|
|
531
|
+
* said before it does it. Matched by exact name; the framework's own columns
|
|
532
|
+
* are left out.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} opts - { driver, connection (config or pool), targetTable, columns: string[] }
|
|
535
|
+
* @returns {Promise<{ exists, matched: string[], extra: string[], missing: [{ name, required }] }>}
|
|
536
|
+
* extra: in the file, not the table — the import would be refused.
|
|
537
|
+
* missing: in the table, not the file — left empty; `required` when the
|
|
538
|
+
* column takes no empty value and has no default, so every row would be refused.
|
|
539
|
+
*/
|
|
540
|
+
async function compareToTable(opts) {
|
|
541
|
+
var driver = opts.driver;
|
|
542
|
+
var poolIsExternal = looksLikePool(opts.connection);
|
|
543
|
+
var pool = poolIsExternal ? opts.connection : await driver.connect(opts.connection);
|
|
544
|
+
try {
|
|
545
|
+
var schema = (await driver.getTableSchema(pool, opts.targetTable)) || [];
|
|
546
|
+
var table = schema.filter(function(c) { return c.name.indexOf('__xeplr_') !== 0; });
|
|
547
|
+
var fileCols = (opts.columns || []).filter(function(n) { return n != null && n !== ''; }).map(String);
|
|
548
|
+
var inTable = {};
|
|
549
|
+
table.forEach(function(c) { inTable[c.name] = c; });
|
|
550
|
+
var inFile = {};
|
|
551
|
+
fileCols.forEach(function(n) { inFile[n] = true; });
|
|
552
|
+
return {
|
|
553
|
+
exists: schema.length > 0,
|
|
554
|
+
matched: fileCols.filter(function(n) { return inTable[n]; }),
|
|
555
|
+
extra: schema.length ? fileCols.filter(function(n) { return !inTable[n]; }) : [],
|
|
556
|
+
missing: table.filter(function(c) { return !inFile[c.name]; }).map(function(c) {
|
|
557
|
+
return { name: c.name, required: c.nullable === false && !c.hasDefault };
|
|
558
|
+
})
|
|
559
|
+
};
|
|
560
|
+
} finally {
|
|
561
|
+
if (!poolIsExternal) await driver.close(pool);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
392
565
|
function safeBatchSize(columnCount, maxParams) {
|
|
393
566
|
var paramsPerRow = columnCount + 1; // +1 for __xeplr_movement_id__
|
|
394
567
|
return Math.max(1, Math.floor(maxParams / paramsPerRow));
|
|
@@ -521,5 +694,7 @@ module.exports = {
|
|
|
521
694
|
wasCommitted: wasCommitted,
|
|
522
695
|
inferColumns: inferColumns,
|
|
523
696
|
inferColumnType: inferColumnType,
|
|
697
|
+
compareToTable: compareToTable,
|
|
698
|
+
MODES: MODES,
|
|
524
699
|
makeMetaStore: require('./meta-store-knex')
|
|
525
700
|
};
|
|
@@ -88,6 +88,9 @@ module.exports = function makeKnexMetaStore(knex, options) {
|
|
|
88
88
|
status: 'running',
|
|
89
89
|
primary_keys: plan.primaryKeys ? JSON.stringify(plan.primaryKeys) : null,
|
|
90
90
|
columns: plan.columns ? JSON.stringify(plan.columns) : null,
|
|
91
|
+
// What the import did to its target ('new' | 'append' | 'replace'):
|
|
92
|
+
// a Replace cannot be rolled back, and rollback reads it here.
|
|
93
|
+
meta: plan.mode ? JSON.stringify({ mode: plan.mode }) : null,
|
|
91
94
|
error: null,
|
|
92
95
|
started_at: knex.fn.now(),
|
|
93
96
|
ended_at: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/actions",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.23",
|
|
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",
|