@xeplr/actions 1.1.21 → 1.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -283,7 +283,7 @@ await actions.uploader.upload({
283
283
  signal, // optional AbortSignal: stop on request
284
284
  staged // optional: see Staged loads
285
285
  })
286
- // → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, merged, durationMs }
286
+ // → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, errorRows, aborted, merged, durationMs }
287
287
  ```
288
288
 
289
289
  `onProgress({ rowsRead, batches, rowsWritten, phase? })` fires after each batch is read, with `rowsWritten` from the write queue's own count, so a caller can show both and see whether writing keeps up. After the read, while the queue drains, it fires again each time the written count moves (`phase: 'writing'`), never on a bare timer, so a stuck write goes quiet.
@@ -292,6 +292,8 @@ await actions.uploader.upload({
292
292
 
293
293
  Rows are spooled to NDJSON files on disk between source and database. From the first batch it creates, if missing: the target table, `<targetTable>_import_errors` (`movement_id`, `row_num`, `error_description`, `underlying_sql`, `raw_row`, `recorded_at`), a `__xeplr_movement_id__` column, and a unique index on `primaryKeys` (not on DuckDB: its upserts are staged and keep none). Server dialects also add `__xeplr_id__`; DuckDB does not.
294
294
 
295
+ **Rows the database refuses are kept, in `<targetTable>_import_errors`.** When a batch fails, the queue splits it until it finds the rows the database will not take; each one is written to the error table with `row_num` (its 1-based place in the source, so the first data row is 1), `error_description` (the database's own message and code, e.g. `… violates check constraint "t_qty_check" [23514]`), the statement, and `raw_row` (the row as JSON, every column). They load nowhere else, so this table is the only way to see, fix and re-load them. `errorRows` in the result says how many were recorded: `{ recorded, notRecorded, reason? }`. Recording is best-effort and never fails the movement; `notRecorded` counts rows that could not be written (with the first `reason`). Rows dropped because the movement gave up (stopped, aborted) are counted in `dropped` but were never refused, so they are not recorded. Rolling a movement back deletes its error rows too. A queue's own `onErrorTable` hook still runs first. The statement is built once for every dialect (`lib/drivers/db/errorRows.js`), exposed per driver as `buildInsertErrorRowsSql(table, movementId, entries)`.
296
+
295
297
  Logical types and what each driver creates:
296
298
 
297
299
  | type | postgres | mysql | mssql | duckdb |
@@ -454,7 +456,7 @@ node --test test/uploader/reconcile.test.js
454
456
 
455
457
  | needs nothing | needs a database |
456
458
  |---|---|
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` |
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` |
458
460
  | `test/list-columns-length.test.js` (`XEPLR_TEST_PG_URL`, `MYSQL_TEST_URL`, `MSSQL_TEST_URL`; each skipped when unset), `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/duckdb-index-guard.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
459
461
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
460
462
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
@@ -966,6 +966,10 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
966
966
  return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' SET DATA TYPE TIMESTAMP';
967
967
  }
968
968
 
969
+ // Rows the database refused, into <table>_import_errors — one builder for every dialect.
970
+ var errorRows = require('./errorRows');
971
+ var placeholders = require('./procedure').placeholders;
972
+
969
973
  module.exports = {
970
974
  buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
971
975
  requires: ['@duckdb/node-api'],
@@ -979,6 +983,9 @@ module.exports = {
979
983
  quoteIdent: quoteIdent,
980
984
  buildCreateTableSql: buildCreateTableSql,
981
985
  buildCreateErrorTableSql: buildCreateErrorTableSql,
986
+ buildInsertErrorRowsSql: function(tableName, movementId, entries) {
987
+ return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
988
+ },
982
989
  buildUpsertIndexSql: buildUpsertIndexSql,
983
990
  buildAlterTableAddSql: buildAlterTableAddSql,
984
991
  buildInsertSql: buildInsertSql,
@@ -0,0 +1,37 @@
1
+ // ROWS THE DATABASE REFUSED, written to `<table>_import_errors`.
2
+ //
3
+ // Every dialect creates that table (buildCreateErrorTableSql) with the same
4
+ // columns; only the quoting and the bind marks differ. So the statement is
5
+ // built once, here, and each driver passes its own quoteIdent and mark.
6
+
7
+ var COLUMNS = ['movement_id', 'row_num', 'error_description', 'underlying_sql', 'raw_row'];
8
+
9
+ /**
10
+ * One INSERT for several refused rows.
11
+ *
12
+ * @param {function} quoteIdent the driver's identifier quoting
13
+ * @param {function} mark bind mark for param n (0-based): '?', '$1', '@p0'
14
+ * @param {string} tableName the TARGET table — the error table is named from it
15
+ * @param {string} movementId
16
+ * @param {Array<{rowNum, error, sql, row}>} entries
17
+ * @returns {{ sql: string, params: any[] }}
18
+ */
19
+ function buildInsertErrorRowsSql(quoteIdent, mark, tableName, movementId, entries) {
20
+ var params = [];
21
+ var groups = entries.map(function(e) {
22
+ var marks = [];
23
+ [movementId, e.rowNum == null ? null : e.rowNum, e.error || null, e.sql || null,
24
+ e.row == null ? null : JSON.stringify(e.row)].forEach(function(v) {
25
+ marks.push(mark(params.length));
26
+ params.push(v);
27
+ });
28
+ return '(' + marks.join(', ') + ')';
29
+ });
30
+ return {
31
+ sql: 'INSERT INTO ' + quoteIdent(tableName + '_import_errors') +
32
+ ' (' + COLUMNS.map(quoteIdent).join(', ') + ') VALUES ' + groups.join(', '),
33
+ params: params
34
+ };
35
+ }
36
+
37
+ module.exports = { buildInsertErrorRowsSql: buildInsertErrorRowsSql };
@@ -450,6 +450,10 @@ async function rollbackMovement(pool, tableName, movementId) {
450
450
  return result;
451
451
  }
452
452
 
453
+ // Rows the database refused, into <table>_import_errors — one builder for every dialect.
454
+ var errorRows = require('./errorRows');
455
+ var placeholders = require('./procedure').placeholders;
456
+
453
457
  module.exports = {
454
458
  requires: ['mssql'],
455
459
  maxParams: MAX_PARAMS,
@@ -465,6 +469,9 @@ module.exports = {
465
469
  quoteIdent: quoteIdent,
466
470
  buildCreateTableSql: buildCreateTableSql,
467
471
  buildCreateErrorTableSql: buildCreateErrorTableSql,
472
+ buildInsertErrorRowsSql: function(tableName, movementId, entries) {
473
+ return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mssql'), tableName, movementId, entries);
474
+ },
468
475
  buildAlterTableAddSql: buildAlterTableAddSql,
469
476
  buildInsertSql: buildInsertSql,
470
477
 
@@ -451,6 +451,10 @@ async function rollbackMovement(pool, tableName, movementId) {
451
451
  return result;
452
452
  }
453
453
 
454
+ // Rows the database refused, into <table>_import_errors — one builder for every dialect.
455
+ var errorRows = require('./errorRows');
456
+ var placeholders = require('./procedure').placeholders;
457
+
454
458
  module.exports = {
455
459
  requires: ['mysql2'],
456
460
  maxParams: MAX_PARAMS,
@@ -466,6 +470,9 @@ module.exports = {
466
470
  quoteIdent: quoteIdent,
467
471
  buildCreateTableSql: buildCreateTableSql,
468
472
  buildCreateErrorTableSql: buildCreateErrorTableSql,
473
+ buildInsertErrorRowsSql: function(tableName, movementId, entries) {
474
+ return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('mysql'), tableName, movementId, entries);
475
+ },
469
476
  buildAlterTableAddSql: buildAlterTableAddSql,
470
477
  buildInsertSql: buildInsertSql,
471
478
 
@@ -403,6 +403,10 @@ function buildRetypeToLocalDateTimeSql(tableName, column) {
403
403
  return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
404
404
  }
405
405
 
406
+ // Rows the database refused, into <table>_import_errors — one builder for every dialect.
407
+ var errorRows = require('./errorRows');
408
+ var placeholders = require('./procedure').placeholders;
409
+
406
410
  module.exports = {
407
411
  buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
408
412
  requires: ['pg'],
@@ -418,6 +422,9 @@ module.exports = {
418
422
  quoteIdent: quoteIdent,
419
423
  buildCreateTableSql: buildCreateTableSql,
420
424
  buildCreateErrorTableSql:buildCreateErrorTableSql,
425
+ buildInsertErrorRowsSql: function(tableName, movementId, entries) {
426
+ return errorRows.buildInsertErrorRowsSql(quoteIdent, placeholders('postgres'), tableName, movementId, entries);
427
+ },
421
428
  buildUpsertIndexSql: buildUpsertIndexSql,
422
429
  buildAlterTableAddSql: buildAlterTableAddSql,
423
430
  buildInsertSql: buildInsertSql,
@@ -96,6 +96,17 @@ async function upload(opts) {
96
96
  var onStop = opts.signal ? function() { opts.queue.abort(opts.movementId, 'stopped'); } : null;
97
97
  if (onStop) opts.signal.addEventListener('abort', onStop, { once: true });
98
98
 
99
+ // WHERE A REFUSED ROW GOES: `<targetTable>_import_errors`, with the
100
+ // database's reason. The queue splits a failing batch down to the rows the
101
+ // database refuses and hands each one to its onErrorTable hook — whose
102
+ // default does nothing, so every one of them used to vanish, leaving only a
103
+ // count ("63 dropped") and nothing anyone could look at, fix or re-load.
104
+ var refused = recordRefusedRows(opts, driver, pool);
105
+ // Each row's place in the source (1-based), to say WHICH row was refused.
106
+ // Keyed by the row object itself: a split batch slices the same objects, so
107
+ // the number survives however far the queue bisects.
108
+ var rowNumbers = refused.rowNumbers;
109
+
99
110
  try {
100
111
  var summary = await spool(opts.source, {
101
112
  runId: opts.movementId,
@@ -156,6 +167,9 @@ async function upload(opts) {
156
167
  onBatch: async function(rows, meta) {
157
168
  if (opts.signal && opts.signal.aborted) throw stoppedError();
158
169
  if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
170
+ for (var n = 0; n < rows.length; n++) {
171
+ if (rows[n] && typeof rows[n] === 'object') rowNumbers.set(rows[n], rowsSeen + n + 1);
172
+ }
159
173
  for (var off = 0; off < rows.length; off += effectiveBatchSize) {
160
174
  var chunk = rows.slice(off, off + effectiveBatchSize);
161
175
  // A NAME FOR THIS BATCH, carried all the way to the failure.
@@ -231,6 +245,7 @@ async function upload(opts) {
231
245
  } finally {
232
246
  if (drainWatch) drainWatch.stop();
233
247
  }
248
+ var errorRows = await refused.flush();
234
249
  // Stopped while the queue drained: not a finished movement.
235
250
  if (opts.signal && opts.signal.aborted) throw stoppedError();
236
251
 
@@ -269,12 +284,17 @@ async function upload(opts) {
269
284
  totalBatches: totalBatches,
270
285
  completed: stats.completed || 0,
271
286
  dropped: stats.dropped || 0,
287
+ // Of the dropped rows, how many are in the error table to look at —
288
+ // { recorded, notRecorded, reason? }. Rows dropped without the database
289
+ // refusing them (a movement that gave up) are counted, never recorded.
290
+ errorRows: errorRows,
272
291
  aborted: !!stats.aborted,
273
292
  // Staged only: what the move put in the target — { inserted, replaced }.
274
293
  merged: merged,
275
294
  durationMs: Date.now() - startedAt
276
295
  };
277
296
  } finally {
297
+ refused.detach();
278
298
  // A staged load that did not move its rows: stopped, failed, aborted.
279
299
  // Its staging table is all it wrote, and dropping it is the whole undo.
280
300
  if (staged && !merged && pool) {
@@ -290,6 +310,66 @@ async function upload(opts) {
290
310
  }
291
311
  }
292
312
 
313
+ // How many refused rows to write in one INSERT into the error table.
314
+ var ERROR_ROWS_PER_INSERT = 200;
315
+
316
+ /**
317
+ * Hook this movement into the queue's onErrorTable, writing every row the
318
+ * database refuses to `<targetTable>_import_errors` — row number, reason,
319
+ * the statement, and the row itself as JSON. Whatever hook the caller gave
320
+ * the queue still runs first. Writing is best-effort: a failure to record is
321
+ * counted and reported, never a failure of the movement.
322
+ *
323
+ * @returns {{ rowNumbers: WeakMap, flush: () => Promise<{recorded, notRecorded, reason?}>, detach: () => void }}
324
+ */
325
+ function recordRefusedRows(opts, driver, pool) {
326
+ var queue = opts.queue;
327
+ var previous = queue.onErrorTable;
328
+ var rowNumbers = new WeakMap();
329
+ var pending = [];
330
+ var writes = [];
331
+ var tally = { recorded: 0, notRecorded: 0, reason: null };
332
+ var canWrite = typeof driver.buildInsertErrorRowsSql === 'function';
333
+
334
+ function write(entries) {
335
+ if (!canWrite) {
336
+ tally.notRecorded += entries.length;
337
+ tally.reason = tally.reason || 'this database driver cannot write the error table';
338
+ return;
339
+ }
340
+ var built = driver.buildInsertErrorRowsSql(opts.targetTable, opts.movementId, entries);
341
+ writes.push(driver.query(pool, built.sql, built.params).then(
342
+ function() { tally.recorded += entries.length; },
343
+ function(err) { tally.notRecorded += entries.length; tally.reason = tally.reason || err.message; }
344
+ ));
345
+ }
346
+
347
+ queue.onErrorTable = function(info) {
348
+ try { if (previous) previous.apply(queue, arguments); } catch (_) { /* the caller's hook cannot stop recording */ }
349
+ var item = info && info.item;
350
+ if (!item || item.movementId !== opts.movementId) return;
351
+ var rows = (item.meta && item.meta.rows) || [];
352
+ var reason = (info.error && info.error.message) || info.reason || 'unknown error';
353
+ if (info.errorCode != null) reason = reason + ' [' + info.errorCode + ']';
354
+ rows.forEach(function(row) {
355
+ pending.push({ rowNum: rowNumbers.get(row), error: reason, sql: rows.length === 1 ? item.sql : null, row: row });
356
+ });
357
+ while (pending.length >= ERROR_ROWS_PER_INSERT) write(pending.splice(0, ERROR_ROWS_PER_INSERT));
358
+ };
359
+
360
+ return {
361
+ rowNumbers: rowNumbers,
362
+ flush: async function() {
363
+ if (pending.length) write(pending.splice(0));
364
+ await Promise.all(writes);
365
+ var out = { recorded: tally.recorded, notRecorded: tally.notRecorded };
366
+ if (tally.reason) out.reason = tally.reason;
367
+ return out;
368
+ },
369
+ detach: function() { queue.onErrorTable = previous; }
370
+ };
371
+ }
372
+
293
373
  /** The error a stopped movement ends with: code 'STOPPED', so a caller can tell it from a failure. */
294
374
  function stoppedError() {
295
375
  var err = new Error('Stopped by request');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.21",
3
+ "version": "1.1.22",
4
4
  "description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@duckdb/node-api": "1.5.5-r.3",
40
+ "@xeplr/db": "^1.0.8",
40
41
  "@xeplr/email": "^1.0.0",
41
42
  "@xeplr/utils": "^1.0.0",
42
43
  "csv-parse": "^7.0.2",
@@ -117,6 +118,7 @@
117
118
  },
118
119
  "devDependencies": {
119
120
  "@duckdb/node-api": "^1.5.5-r.3",
121
+ "@xeplr/db": "^1.0.8",
120
122
  "@xeplr/utils": "^1.0.0",
121
123
  "csv-parse": "^7.0.2",
122
124
  "dejavu-fonts-ttf": "^2.37.3",