@xeplr/actions 1.1.22 → 1.1.24

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`. |
@@ -331,6 +331,27 @@ With `staged: true` a load **never writes into its target while it runs**. Batch
331
331
 
332
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.
333
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
+
351
+ ### The uploader's own tables
352
+
353
+ Beside the tables it loads, the uploader makes three of its own: `<table>_import_errors` (refused rows), `<table>__xeplr_staging` (a staged load in progress) and, in a DuckDB file, `__xeplr_loads` (its ledger). `isInternalTable(name)` (also accepts a listing row `{ name }`) says whether a table is one of these, so an app listing a database's tables for people to pick from can leave them out. They still exist and are read where they belong.
354
+
334
355
  ### A written DuckDB file keeps no index
335
356
 
336
357
  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.
@@ -456,7 +477,7 @@ node --test test/uploader/reconcile.test.js
456
477
 
457
478
  | needs nothing | needs a database |
458
479
  |---|---|
459
- | `test/formats/pasted-rows.test.js` (its last case loads into Postgres when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, else skipped), `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, which needs `@xeplr/db`: a peer dependency, installed for development) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js` (also honours `PG_HOST`/`PG_PORT`/`PG_USER`), `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
480
+ | `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` |
460
481
  | `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` |
461
482
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
462
483
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
package/index.js CHANGED
@@ -37,6 +37,8 @@ module.exports = {
37
37
  // their own — e.g. a UI wizard showing inferred columns from a CSV sample
38
38
  // before actually committing to an import).
39
39
  uploader: uploader,
40
+ // The uploader's own tables (_import_errors, __xeplr_staging, __xeplr_loads), to leave out of table pickers.
41
+ isInternalTable: require('./lib/drivers/db/internalTables').isInternalTable,
40
42
  inferColumns: uploader.inferColumns,
41
43
  streaming: streaming,
42
44
  drivers: { db: dbDrivers, file: fileDrivers },
@@ -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 + ')');
@@ -0,0 +1,31 @@
1
+ // THE TABLES THE UPLOADER MAKES FOR ITSELF, beside the tables it loads:
2
+ //
3
+ // <table>_import_errors rows the database refused (errorRows.js)
4
+ // <table>__xeplr_staging a staged load's rows until they move in (staging.js, duckdb.js)
5
+ // __xeplr_loads a DuckDB file's ledger of staged loads (duckdb.js)
6
+ //
7
+ // They are the uploader's bookkeeping, not data anybody models: an app that
8
+ // lists a database's tables for people to pick from (a dataset builder, a
9
+ // cube's source picker) leaves them out with isInternalTable(). They still
10
+ // exist and are read where they belong (an import's dropped rows).
11
+
12
+ var ERRORS_SUFFIX = '_import_errors';
13
+ var STAGING_SUFFIX = require('./staging').SUFFIX;
14
+ var LOADS_LEDGER = '__xeplr_loads';
15
+
16
+ function endsWith(s, suffix) {
17
+ return s.length > suffix.length && s.slice(-suffix.length) === suffix;
18
+ }
19
+
20
+ /** Whether a table is one the uploader made for itself. Case as the database reports it. */
21
+ function isInternalTable(name) {
22
+ var s = String(name == null ? '' : (typeof name === 'object' ? name.name || name.table || '' : name));
23
+ return endsWith(s, ERRORS_SUFFIX) || endsWith(s, STAGING_SUFFIX) || s === LOADS_LEDGER;
24
+ }
25
+
26
+ module.exports = {
27
+ isInternalTable: isInternalTable,
28
+ ERRORS_SUFFIX: ERRORS_SUFFIX,
29
+ STAGING_SUFFIX: STAGING_SUFFIX,
30
+ LOADS_LEDGER: LOADS_LEDGER
31
+ };
@@ -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,11 +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
+
453
480
  // Rows the database refused, into <table>_import_errors — one builder for every dialect.
454
481
  var errorRows = require('./errorRows');
455
482
  var placeholders = require('./procedure').placeholders;
456
483
 
457
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,
458
490
  requires: ['mssql'],
459
491
  maxParams: MAX_PARAMS,
460
492
 
@@ -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,11 +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
+
454
478
  // Rows the database refused, into <table>_import_errors — one builder for every dialect.
455
479
  var errorRows = require('./errorRows');
456
480
  var placeholders = require('./procedure').placeholders;
457
481
 
458
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,
459
488
  requires: ['mysql2'],
460
489
  maxParams: MAX_PARAMS,
461
490
 
@@ -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,11 +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
+
406
430
  // Rows the database refused, into <table>_import_errors — one builder for every dialect.
407
431
  var errorRows = require('./errorRows');
408
432
  var placeholders = require('./procedure').placeholders;
409
433
 
410
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,
411
440
  buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
412
441
  requires: ['pg'],
413
442
 
@@ -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.
@@ -108,6 +111,11 @@ async function upload(opts) {
108
111
  var rowNumbers = refused.rowNumbers;
109
112
 
110
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
+
111
119
  var summary = await spool(opts.source, {
112
120
  runId: opts.movementId,
113
121
  batchDir: opts.batchDir,
@@ -154,6 +162,7 @@ async function upload(opts) {
154
162
  dbType: opts.dbType, // recorded when the caller supplies it
155
163
  columns: columns,
156
164
  primaryKeys: opts.primaryKeys,
165
+ mode: opts.mode || null,
157
166
  mtId1: opts.mtId1, mtId2: opts.mtId2, mtId3: opts.mtId3, mtId4: opts.mtId4,
158
167
  details: opts.details
159
168
  });
@@ -265,7 +274,8 @@ async function upload(opts) {
265
274
  columns: columns,
266
275
  primaryKeys: opts.primaryKeys,
267
276
  movementId: opts.movementId,
268
- replaceFrom: opts.replaceFrom
277
+ replaceFrom: opts.replaceFrom,
278
+ replaceAll: opts.replaceAll
269
279
  });
270
280
  }
271
281
  await opts.metaStore.recordEnd(opts.movementId, {
@@ -290,7 +300,9 @@ async function upload(opts) {
290
300
  errorRows: errorRows,
291
301
  aborted: !!stats.aborted,
292
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.
293
304
  merged: merged,
305
+ mode: opts.mode || null,
294
306
  durationMs: Date.now() - startedAt
295
307
  };
296
308
  } finally {
@@ -396,6 +408,13 @@ async function rollback(opts) {
396
408
 
397
409
  if (opts.metaStore && opts.metaStore.getById) {
398
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
+ }
399
418
  if (meta && meta.primary_keys && meta.primary_keys.length) {
400
419
  throw new Error(
401
420
  'rollback: this movement used upsert (primary keys: ' + meta.primary_keys.join(', ') + ') — ' +
@@ -458,6 +477,18 @@ function normalizeOptions(opts) {
458
477
  if (opts.driver && opts.driver.stagesUpserts && (opts.primaryKeys || []).length) opts.staged = true;
459
478
  opts.writeTable = null;
460
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
+ }
461
492
  if (opts.replaceFrom) {
462
493
  if (!opts.replaceFrom.column) throw new Error('upload: replaceFrom needs a column');
463
494
  if ((opts.primaryKeys || []).length) throw new Error('upload: replaceFrom replaces a range, so it takes no primaryKeys');
@@ -466,9 +497,71 @@ function normalizeOptions(opts) {
466
497
  if (opts.staged && typeof opts.driver.mergeStaged !== 'function') {
467
498
  throw new Error('upload: staged loads are not supported for this database — its driver has no mergeStaged');
468
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
+ }
469
503
  return opts;
470
504
  }
471
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
+
472
565
  function safeBatchSize(columnCount, maxParams) {
473
566
  var paramsPerRow = columnCount + 1; // +1 for __xeplr_movement_id__
474
567
  return Math.max(1, Math.floor(maxParams / paramsPerRow));
@@ -601,5 +694,7 @@ module.exports = {
601
694
  wasCommitted: wasCommitted,
602
695
  inferColumns: inferColumns,
603
696
  inferColumnType: inferColumnType,
697
+ compareToTable: compareToTable,
698
+ MODES: MODES,
604
699
  makeMetaStore: require('./meta-store-knex')
605
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.22",
3
+ "version": "1.1.24",
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": [