@xeplr/actions 1.1.12 → 1.1.14

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
@@ -189,6 +189,8 @@ await actions.uploader.upload({
189
189
  // → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, durationMs }
190
190
  ```
191
191
 
192
+ `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.
193
+
192
194
  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`. Server dialects also add `__xeplr_id__`; DuckDB does not.
193
195
 
194
196
  Logical types and what each driver creates:
@@ -293,6 +295,8 @@ await driver.query(pool, userSql, params) // reads only
293
295
  | **Rollback is refused for a movement that used `primaryKeys`.** | Upsert stamps the movement id on rows it only updated, so deleting by it would remove rows that already existed. |
294
296
  | Movement logs carry identifiers, counts, SQL and the error text — never parameter values or row contents; a failed batch shows one row's primary key, not its data. | Logs are copied into tickets and chats. |
295
297
  | DuckDB opens **read-only unless the call says `access: 'rw'`**; no environment variable can grant write. A file already open read-only in the process refuses a write. Opening waits on the lock (`openTimeoutMs`, default 60 s). | One read-write process locks every other process out, even readers. Write access belongs at the call site that writes. |
298
+ | **Every DuckDB value comes out plain**, from `query` and `fetchStream` alike: a number, text, boolean, `Date`, array or object, never a BigInt or a DuckDB wrapper object. DECIMAL is a number; DATE and TIMESTAMP are `Date`s; a whole number too big to hold exactly (beyond ±9,007,199,254,740,991) is **text**, never rounded; lists, structs and maps are arrays and objects; TIME, INTERVAL, UUID and any other type are text. `fetchStream` has DuckDB do the conversion inside its engine by wrapping the query (about +3% on a million rows); a query with nothing to convert runs as given. | A streamed read used to hand DECIMAL and TIMESTAMP back as wrapper objects with a BigInt inside, and the first `JSON.stringify` downstream failed with "Do not know how to serialize a BigInt". Text for huge numbers is what the Postgres, MySQL and SQL Server drivers already return. |
299
+ | **A DuckDB timestamp without a zone is read as UTC**; one with a zone keeps its exact instant. | Data is kept in UTC; the viewer's time zone is applied when rendering, never in the driver. |
296
300
  | `meta-store-knex` requires `service` and `applicationId`, and scopes every statement by `applicationId`. | `import_meta` is shared by every app; unattributed rows, or a rollback decided from another app's row, are worse than refusing. |
297
301
  | Mail filenames are sanitised (`[^\w.-]` → `_`, all-dot names → `attachment`, max 180 chars) and prefixed with a random id. | Attachment names come from the sender and could escape the output directory; common names collide. |
298
302
  | The system mail sender refuses `text`, `bcc`, `from`, `replyTo` rather than dropping them. | A silently lost bcc is a compliance problem found a year later. |
@@ -308,8 +312,8 @@ node --test test/uploader/reconcile.test.js
308
312
 
309
313
  | needs nothing | needs a database |
310
314
  |---|---|
311
- | `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.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` |
312
- | `test/drivers/duckdb.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
315
+ | `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.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` |
316
+ | `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
313
317
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
314
318
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
315
319
 
@@ -299,11 +299,27 @@ async function close(pool) {
299
299
 
300
300
  // BigInt is what DuckDB hands back for BIGINT/HUGEINT, and it poisons anything
301
301
  // downstream that does arithmetic or JSON.stringify on a result. Narrowed to a
302
- // number where that is lossless, left alone where it is not — silently losing
303
- // precision on a genuinely huge value would be worse than an awkward type.
302
+ // number where that is lossless, and to TEXT where it is not: silently losing
303
+ // digits on a genuinely huge value would be wrong, and a BigInt left in a row
304
+ // crashes the first JSON.stringify that meets it. Text is also what Postgres,
305
+ // MySQL and SQL Server already return for such a value, so every source
306
+ // behaves alike.
304
307
  function unBig(v) {
305
308
  if (typeof v !== 'bigint') return v;
306
- return (v <= 9007199254740991n && v >= -9007199254740991n) ? Number(v) : v;
309
+ return (v <= 9007199254740991n && v >= -9007199254740991n) ? Number(v) : v.toString();
310
+ }
311
+
312
+ // The same, all the way down: a BigInt can sit INSIDE a value the normal read
313
+ // returns (an INTERVAL's micros, a list of BIGINTs). Arrays and plain objects
314
+ // keep their shape; a Date, a string or a number is left as it is.
315
+ function plainDeep(v) {
316
+ if (typeof v === 'bigint') return unBig(v);
317
+ if (v === null || typeof v !== 'object' || v instanceof Date) return v;
318
+ if (Array.isArray(v)) return v.map(plainDeep);
319
+ if (Object.getPrototypeOf(v) !== Object.prototype) return v;
320
+ var out = {};
321
+ for (var k in v) out[k] = plainDeep(v[k]);
322
+ return out;
307
323
  }
308
324
 
309
325
  /**
@@ -337,7 +353,7 @@ async function query(pool, sql, params) {
337
353
  var columns = reader.columnNames();
338
354
  var rows = reader.getRowObjectsJS().map(function(row) {
339
355
  var out = {};
340
- for (var k in row) out[k] = unBig(row[k]);
356
+ for (var k in row) out[k] = plainDeep(row[k]);
341
357
  return out;
342
358
  });
343
359
  return { rows: rows, rowCount: rows.length, columns: columns };
@@ -551,6 +567,108 @@ async function listProcedures(_pool, _schema) {
551
567
  return [];
552
568
  }
553
569
 
570
+ // ── plain values out of a stream ─────────────────────────────────────────
571
+ //
572
+ // A streamed chunk (getRowObjects) hands DECIMAL, TIMESTAMP, INTERVAL, UUID
573
+ // and the nested types back as DuckDB WRAPPER objects, several with a BigInt
574
+ // inside. They are not the values the rest of the code expects, and the first
575
+ // JSON.stringify that meets one fails ("Do not know how to serialize a
576
+ // BigInt") — which is how a report grouped by an amount or a timestamp from a
577
+ // cube used to die.
578
+ //
579
+ // Converting each wrapper in JavaScript doubles the cost of a streamed read.
580
+ // So DuckDB converts them inside its own engine instead, by wrapping the
581
+ // query: measured on 1M rows, +2% (+6% with the timestamp turned into a Date
582
+ // here), against +100% for the JS converter.
583
+ //
584
+ // passes through booleans, integers (BigInt narrowed by unBig), floats,
585
+ // text, enums
586
+ // DECIMAL DOUBLE: what the non-streamed read (getRowObjectsJS)
587
+ // returns too
588
+ // DATE, TIMESTAMP* epoch milliseconds, turned into a Date as the row is
589
+ // built — the same type Postgres and the normal read give.
590
+ // A timestamp WITHOUT a zone is read as UTC (data is kept
591
+ // in UTC; the viewer's zone is applied when rendering). One
592
+ // WITH a zone keeps its exact instant: epoch_ms is taken on
593
+ // it directly, never after a cast that would apply the
594
+ // session's zone.
595
+ // LIST, STRUCT, JSON, parsed back here: arrays and objects, the shape the
596
+ // MAP, ARRAY normal read gives
597
+ // anything else VARCHAR (TIME, INTERVAL, UUID, …). A type nobody has met
598
+ // yet becomes text rather than a crash.
599
+ //
600
+ // When no column needs converting, the query runs exactly as given.
601
+ var PASS_THROUGH_TYPES = null;
602
+ var DATE_TYPES = null;
603
+ var NESTED_TYPES = null;
604
+ function typeSets() {
605
+ if (PASS_THROUGH_TYPES) return;
606
+ var T = require('@duckdb/node-api').DuckDBTypeId;
607
+ PASS_THROUGH_TYPES = new Set([T.BOOLEAN, T.TINYINT, T.SMALLINT, T.INTEGER, T.BIGINT, T.HUGEINT,
608
+ T.UTINYINT, T.USMALLINT, T.UINTEGER, T.UBIGINT, T.UHUGEINT, T.FLOAT, T.DOUBLE,
609
+ T.VARCHAR, T.ENUM, T.SQLNULL].filter(function(x) { return x !== undefined; }));
610
+ DATE_TYPES = {};
611
+ DATE_TYPES[T.DATE] = 'plain';
612
+ DATE_TYPES[T.TIMESTAMP] = 'plain';
613
+ DATE_TYPES[T.TIMESTAMP_S] = 'plain';
614
+ DATE_TYPES[T.TIMESTAMP_MS] = 'plain';
615
+ DATE_TYPES[T.TIMESTAMP_NS] = 'plain';
616
+ DATE_TYPES[T.TIMESTAMP_TZ] = 'zoned';
617
+ NESTED_TYPES = new Set([T.LIST, T.STRUCT, T.MAP, T.ARRAY, T.UNION].filter(function(x) { return x !== undefined; }));
618
+ }
619
+
620
+ function stripTrailingSemicolon(sql) {
621
+ return String(sql).replace(/[\s;]+$/, '');
622
+ }
623
+
624
+ async function plainValuesPlan(conn, sql) {
625
+ typeSets();
626
+ var prepared = await conn.prepare(sql);
627
+ var count = prepared.columnCount;
628
+ var select = [];
629
+ var inner = [];
630
+ var dateColumns = [];
631
+ var jsonColumns = [];
632
+ var changed = false;
633
+ for (var i = 0; i < count; i++) {
634
+ var name = prepared.columnName(i);
635
+ var typeId = prepared.columnTypeId(i);
636
+ var col = '"__c' + i + '"';
637
+ inner.push(col);
638
+ var expr;
639
+ if (PASS_THROUGH_TYPES.has(typeId)) {
640
+ expr = col;
641
+ } else if (DATE_TYPES[typeId] === 'zoned') {
642
+ expr = 'epoch_ms(' + col + ')';
643
+ dateColumns.push(name);
644
+ changed = true;
645
+ } else if (DATE_TYPES[typeId] === 'plain') {
646
+ expr = 'epoch_ms(CAST(' + col + ' AS TIMESTAMP))';
647
+ dateColumns.push(name);
648
+ changed = true;
649
+ } else if (typeId === require('@duckdb/node-api').DuckDBTypeId.DECIMAL) {
650
+ expr = 'CAST(' + col + ' AS DOUBLE)';
651
+ changed = true;
652
+ } else if (NESTED_TYPES.has(typeId)) {
653
+ expr = 'CAST(to_json(' + col + ') AS VARCHAR)';
654
+ jsonColumns.push(name);
655
+ changed = true;
656
+ } else {
657
+ expr = 'CAST(' + col + ' AS VARCHAR)';
658
+ changed = true;
659
+ }
660
+ select.push(expr + ' AS ' + quoteIdent(name));
661
+ }
662
+ if (!changed) return { sql: sql, dateColumns: [], jsonColumns: [] };
663
+ // Positional renames (__c0, __c1, …) so two source columns with one name
664
+ // stay two columns, whatever they are called.
665
+ return {
666
+ sql: 'SELECT ' + select.join(', ') + ' FROM (' + stripTrailingSemicolon(sql) + '\n) AS "__plain"(' + inner.join(', ') + ')',
667
+ dateColumns: dateColumns,
668
+ jsonColumns: jsonColumns
669
+ };
670
+ }
671
+
554
672
  // ── streaming read ───────────────────────────────────────────────────────
555
673
 
556
674
  /**
@@ -565,8 +683,11 @@ async function* fetchStream(pool, opts) {
565
683
  var entry = entryOf(pool);
566
684
  var conn = await acquire(entry);
567
685
  try {
568
- var result = await conn.stream(opts.sql, (opts.params && opts.params.length) ? opts.params : undefined);
686
+ var plan = await plainValuesPlan(conn, opts.sql);
687
+ var result = await conn.stream(plan.sql, (opts.params && opts.params.length) ? opts.params : undefined);
569
688
  var names = result.columnNames();
689
+ var dates = plan.dateColumns;
690
+ var jsons = plan.jsonColumns;
570
691
  while (true) {
571
692
  var chunk = await result.fetchChunk();
572
693
  if (!chunk || chunk.rowCount === 0) break;
@@ -575,6 +696,14 @@ async function* fetchStream(pool, opts) {
575
696
  var row = rows[i];
576
697
  var out = {};
577
698
  for (var k in row) out[k] = unBig(row[k]);
699
+ for (var d = 0; d < dates.length; d++) {
700
+ var v = out[dates[d]];
701
+ if (v !== null && v !== undefined) out[dates[d]] = new Date(v);
702
+ }
703
+ for (var j = 0; j < jsons.length; j++) {
704
+ var jv = out[jsons[j]];
705
+ if (typeof jv === 'string') out[jsons[j]] = JSON.parse(jv);
706
+ }
578
707
  yield out;
579
708
  }
580
709
  }
@@ -154,9 +154,12 @@ async function upload(opts) {
154
154
  // No total, because for a query source there isn't one to know
155
155
  // without running it twice. A caller that wants a percentage has to
156
156
  // supply its own denominator.
157
+ //
158
+ // rowsWritten is what HAS landed, from the write queue's own count,
159
+ // so a caller can show both and see whether writing keeps up.
157
160
  if (opts.onProgress) {
158
161
  try {
159
- opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches });
162
+ opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches, rowsWritten: writtenSoFar(opts) });
160
163
  } catch (e) { /* a reporting failure must not fail the movement */ }
161
164
  }
162
165
  }
@@ -191,7 +194,19 @@ async function upload(opts) {
191
194
  }
192
195
 
193
196
  // Wait for the DB writes to finish (spool has finished feeding the queue).
194
- await opts.queue.drain();
197
+ //
198
+ // The read is over but the queue can take minutes more to drain, and
199
+ // nothing reported progress while it did — so a caller watching for
200
+ // progress saw a busy movement go silent. Reported here whenever the
201
+ // written count MOVES (never on a bare timer: a stuck write must go quiet).
202
+ var drainWatch = opts.onProgress ? watchWrites(opts, function() {
203
+ return { rowsRead: rowsSeen, batches: totalBatches, rowsWritten: writtenSoFar(opts), phase: 'writing' };
204
+ }) : null;
205
+ try {
206
+ await opts.queue.drain();
207
+ } finally {
208
+ if (drainWatch) drainWatch.stop();
209
+ }
195
210
 
196
211
  var stats = opts.queue.stats(opts.movementId) || {};
197
212
  var status = stats.aborted ? 'aborted' : 'completed';
@@ -332,6 +347,31 @@ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys) {
332
347
  return reconciled;
333
348
  }
334
349
 
350
+ /** Rows this movement has written so far, from the write queue's own count. */
351
+ function writtenSoFar(opts) {
352
+ try {
353
+ var st = opts.queue && opts.queue.stats ? opts.queue.stats(opts.movementId) : null;
354
+ return st && st.completed != null ? st.completed : null;
355
+ } catch (e) { return null; }
356
+ }
357
+
358
+ /**
359
+ * While the queue drains, report progress each time the written count moves.
360
+ * Checked every `everyMs`; a count that has not moved reports nothing, so a
361
+ * stuck write goes quiet rather than looking alive.
362
+ */
363
+ function watchWrites(opts, build, everyMs) {
364
+ var last = writtenSoFar(opts);
365
+ var timer = setInterval(function() {
366
+ var now = writtenSoFar(opts);
367
+ if (now == null || now === last) return;
368
+ last = now;
369
+ try { opts.onProgress(build()); } catch (e) { /* reporting must not fail the movement */ }
370
+ }, everyMs || 2000);
371
+ if (timer.unref) timer.unref();
372
+ return { stop: function() { clearInterval(timer); } };
373
+ }
374
+
335
375
  async function enqueueBatch(opts, rows, columns, batchRef) {
336
376
  var driver = opts.driver;
337
377
  var target = opts.targetTable;
@@ -365,6 +405,7 @@ var { inferColumnType } = require('./infer');
365
405
 
366
406
  module.exports = {
367
407
  upload: upload,
408
+ watchWrites: watchWrites,
368
409
  rollback: rollback,
369
410
  inferColumns: inferColumns,
370
411
  inferColumnType: inferColumnType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
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": [