@xeplr/actions 1.1.13 → 1.1.15

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
@@ -184,11 +184,16 @@ await actions.uploader.upload({
184
184
  columns, // optional [{ name, type }] — declared; skips inference
185
185
  batchSize: 5000, firstBatchScanRows: 1000,
186
186
  metaStore, mtId1, mtId2, mtId3, mtId4, details,
187
- errorTable, batchDir, keepBatchFiles, onProgress
187
+ errorTable, batchDir, keepBatchFiles, onProgress,
188
+ signal // optional AbortSignal: stop on request
188
189
  })
189
190
  // → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, durationMs }
190
191
  ```
191
192
 
193
+ `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.
194
+
195
+ `signal` (an `AbortSignal`, passed to db-move as `system.signal`) stops a running movement: the writes still queued are dropped at once (in-flight ones settle), the read stops at the next batch, and it ends with an error whose `code` is `'STOPPED'` (the runner keeps `error.code`), neither a failure nor a success. What it wrote stays, under its `movementId`, for the caller to roll back or keep.
196
+
192
197
  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
198
 
194
199
  Logical types and what each driver creates:
@@ -310,7 +315,7 @@ node --test test/uploader/reconcile.test.js
310
315
 
311
316
  | needs nothing | needs a database |
312
317
  |---|---|
313
- | `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` |
318
+ | `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` |
314
319
  | `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` |
315
320
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
316
321
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
@@ -304,7 +304,9 @@ module.exports = {
304
304
  // Passed through from the caller's `system`, not from `input` — a
305
305
  // progress reporter is a function, and an action's input is data that
306
306
  // has to survive being stored in a job row.
307
- onProgress: system.onProgress || null
307
+ onProgress: system.onProgress || null,
308
+ // An AbortSignal from the caller: stop reading, drop the queued writes.
309
+ signal: system.signal || null
308
310
  });
309
311
 
310
312
  // Every count upload() has, not just the headline one — "read" and
package/lib/runner.js CHANGED
@@ -109,6 +109,8 @@ function finish(start, status, output, err) {
109
109
  if (err.stack) e.stack = err.stack;
110
110
  if (err.details) e.details = err.details;
111
111
  if (err.actionName) e.actionName = err.actionName;
112
+ // Kept, so a caller can tell a movement STOPPED on request from one that failed.
113
+ if (err.code) e.code = err.code;
112
114
  }
113
115
  return { status: status, output: output, error: e, durationMs: Date.now() - start };
114
116
  }
@@ -77,6 +77,14 @@ async function upload(opts) {
77
77
  var spoolResult = null;
78
78
  var runDir = null;
79
79
 
80
+ // STOPPING ON REQUEST (opts.signal, an AbortSignal). The writes still queued
81
+ // are dropped the moment it fires — in-flight ones settle — and the read
82
+ // stops at the next batch. What was written stays, identified by
83
+ // movementId, for the caller to roll back or keep.
84
+ if (opts.signal && opts.signal.aborted) throw stoppedError();
85
+ var onStop = opts.signal ? function() { opts.queue.abort(opts.movementId, 'stopped'); } : null;
86
+ if (onStop) opts.signal.addEventListener('abort', onStop, { once: true });
87
+
80
88
  try {
81
89
  var summary = await spool(opts.source, {
82
90
  runId: opts.movementId,
@@ -131,6 +139,7 @@ async function upload(opts) {
131
139
  // in turn awaits spool's next batch — the whole pipeline throttles
132
140
  // correctly, but disk (not memory) is the buffer.
133
141
  onBatch: async function(rows, meta) {
142
+ if (opts.signal && opts.signal.aborted) throw stoppedError();
134
143
  if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
135
144
  for (var off = 0; off < rows.length; off += effectiveBatchSize) {
136
145
  var chunk = rows.slice(off, off + effectiveBatchSize);
@@ -154,9 +163,12 @@ async function upload(opts) {
154
163
  // No total, because for a query source there isn't one to know
155
164
  // without running it twice. A caller that wants a percentage has to
156
165
  // supply its own denominator.
166
+ //
167
+ // rowsWritten is what HAS landed, from the write queue's own count,
168
+ // so a caller can show both and see whether writing keeps up.
157
169
  if (opts.onProgress) {
158
170
  try {
159
- opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches });
171
+ opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches, rowsWritten: writtenSoFar(opts) });
160
172
  } catch (e) { /* a reporting failure must not fail the movement */ }
161
173
  }
162
174
  }
@@ -191,7 +203,21 @@ async function upload(opts) {
191
203
  }
192
204
 
193
205
  // Wait for the DB writes to finish (spool has finished feeding the queue).
194
- await opts.queue.drain();
206
+ //
207
+ // The read is over but the queue can take minutes more to drain, and
208
+ // nothing reported progress while it did — so a caller watching for
209
+ // progress saw a busy movement go silent. Reported here whenever the
210
+ // written count MOVES (never on a bare timer: a stuck write must go quiet).
211
+ var drainWatch = opts.onProgress ? watchWrites(opts, function() {
212
+ return { rowsRead: rowsSeen, batches: totalBatches, rowsWritten: writtenSoFar(opts), phase: 'writing' };
213
+ }) : null;
214
+ try {
215
+ await opts.queue.drain();
216
+ } finally {
217
+ if (drainWatch) drainWatch.stop();
218
+ }
219
+ // Stopped while the queue drained: not a finished movement.
220
+ if (opts.signal && opts.signal.aborted) throw stoppedError();
195
221
 
196
222
  var stats = opts.queue.stats(opts.movementId) || {};
197
223
  var status = stats.aborted ? 'aborted' : 'completed';
@@ -221,9 +247,17 @@ async function upload(opts) {
221
247
  try { await fsp.rm(runDir, { recursive: true, force: true }); } catch (_) {}
222
248
  }
223
249
  if (!poolIsExternal && pool) await driver.close(pool);
250
+ if (onStop) opts.signal.removeEventListener('abort', onStop);
224
251
  }
225
252
  }
226
253
 
254
+ /** The error a stopped movement ends with: code 'STOPPED', so a caller can tell it from a failure. */
255
+ function stoppedError() {
256
+ var err = new Error('Stopped by request');
257
+ err.code = 'STOPPED';
258
+ return err;
259
+ }
260
+
227
261
  // Rollback a movement — drop from main + error tables, clear meta row.
228
262
  // Graceful queue abort first (in-flight SQLs allowed to settle).
229
263
  //
@@ -332,6 +366,31 @@ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys) {
332
366
  return reconciled;
333
367
  }
334
368
 
369
+ /** Rows this movement has written so far, from the write queue's own count. */
370
+ function writtenSoFar(opts) {
371
+ try {
372
+ var st = opts.queue && opts.queue.stats ? opts.queue.stats(opts.movementId) : null;
373
+ return st && st.completed != null ? st.completed : null;
374
+ } catch (e) { return null; }
375
+ }
376
+
377
+ /**
378
+ * While the queue drains, report progress each time the written count moves.
379
+ * Checked every `everyMs`; a count that has not moved reports nothing, so a
380
+ * stuck write goes quiet rather than looking alive.
381
+ */
382
+ function watchWrites(opts, build, everyMs) {
383
+ var last = writtenSoFar(opts);
384
+ var timer = setInterval(function() {
385
+ var now = writtenSoFar(opts);
386
+ if (now == null || now === last) return;
387
+ last = now;
388
+ try { opts.onProgress(build()); } catch (e) { /* reporting must not fail the movement */ }
389
+ }, everyMs || 2000);
390
+ if (timer.unref) timer.unref();
391
+ return { stop: function() { clearInterval(timer); } };
392
+ }
393
+
335
394
  async function enqueueBatch(opts, rows, columns, batchRef) {
336
395
  var driver = opts.driver;
337
396
  var target = opts.targetTable;
@@ -365,6 +424,8 @@ var { inferColumnType } = require('./infer');
365
424
 
366
425
  module.exports = {
367
426
  upload: upload,
427
+ watchWrites: watchWrites,
428
+ stoppedError: stoppedError,
368
429
  rollback: rollback,
369
430
  inferColumns: inferColumns,
370
431
  inferColumnType: inferColumnType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
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": [