@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 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, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
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,
@@ -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 };
@@ -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
 
@@ -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 };
@@ -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
  };
@@ -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.21",
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",