@nmakarov/cli-toolkit 0.78.0 → 0.80.0

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.
@@ -2654,13 +2654,14 @@ var Logger = class _Logger {
2654
2654
  */
2655
2655
  progress(message, opts) {
2656
2656
  const { prefix, count, total } = opts;
2657
- const paddedTotal = String(total).length;
2657
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
2658
+ const paddedTotal = String(displayTotal).length;
2658
2659
  const paddedCount = String(count).padStart(paddedTotal, " ");
2659
2660
  const payload = {
2660
2661
  level: "progress",
2661
2662
  message,
2662
2663
  count: paddedCount,
2663
- total,
2664
+ total: displayTotal,
2664
2665
  prefix
2665
2666
  };
2666
2667
  const key = prefix ?? "";
@@ -2677,7 +2678,7 @@ var Logger = class _Logger {
2677
2678
  if (wantTimes) {
2678
2679
  let remaining = -1;
2679
2680
  if (itemsPerSec > 0) {
2680
- remaining = (total - count) / itemsPerSec;
2681
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
2681
2682
  }
2682
2683
  payload.elapsed = this.round(elapsedSeconds, 2);
2683
2684
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -2686,12 +2687,12 @@ var Logger = class _Logger {
2686
2687
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
2687
2688
  }
2688
2689
  }
2689
- if (count >= total) {
2690
+ if (count === total) {
2690
2691
  delete this.startTimes[key];
2691
2692
  delete this.startCounts[key];
2692
2693
  delete this.lastProgressTimes[key];
2693
2694
  }
2694
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
2695
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
2695
2696
  this.out(payload);
2696
2697
  if (this.options.progressThrottle && prefix) {
2697
2698
  this.lastProgressTimes[prefix] = Date.now();
@@ -2850,6 +2851,8 @@ function setup(opts = {}) {
2850
2851
  // a quick "show me the figured params and quit" that skips the flow's
2851
2852
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
2852
2853
  // cleanups are skipped); call it once components/params are resolved.
2854
+ _requestExitCode: null,
2855
+ requestExit: null,
2853
2856
  showUsedParamsIfNeeded: () => {
2854
2857
  const mode = params.getShowUsedParamsMode?.();
2855
2858
  if (mode !== "top" && mode !== "stop") return;
@@ -2860,6 +2863,9 @@ function setup(opts = {}) {
2860
2863
  }
2861
2864
  }
2862
2865
  };
2866
+ context.requestExit = (code = 0) => {
2867
+ context._requestExitCode = code;
2868
+ };
2863
2869
  logger.debug("[setup] completed successfully");
2864
2870
  return context;
2865
2871
  }
@@ -2895,9 +2901,25 @@ async function init(flow2, opts = {}) {
2895
2901
  if (cleanupRan) return;
2896
2902
  cleanupRan = true;
2897
2903
  const fns = [...ctx.cleanupFunctions].reverse();
2904
+ const budgetMs = 5e3;
2905
+ const started = Date.now();
2898
2906
  for (const fn of fns) {
2907
+ const left = budgetMs - (Date.now() - started);
2908
+ if (left <= 0) {
2909
+ ctx.logger.warn("[cleanup] budget exhausted \u2014 skipping remaining cleanup");
2910
+ break;
2911
+ }
2899
2912
  try {
2900
- await fn(ctx);
2913
+ await Promise.race([
2914
+ Promise.resolve(fn(ctx)),
2915
+ new Promise((_, reject) => {
2916
+ const t = setTimeout(
2917
+ () => reject(new Error(`cleanup timed out after ${left}ms`)),
2918
+ left
2919
+ );
2920
+ t.unref?.();
2921
+ })
2922
+ ]);
2901
2923
  } catch (error) {
2902
2924
  ctx.logger.warn("[cleanup] error in cleanup function:", error);
2903
2925
  }
@@ -2993,6 +3015,8 @@ async function init(flow2, opts = {}) {
2993
3015
  process.exit(process.exitCode);
2994
3016
  } else if (stop || kill) {
2995
3017
  process.exit(0);
3018
+ } else if (context._requestExitCode != null) {
3019
+ process.exit(context._requestExitCode);
2996
3020
  }
2997
3021
  }
2998
3022
  }
@@ -3116,7 +3140,7 @@ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3116
3140
  "57P02",
3117
3141
  "57P03"
3118
3142
  ]);
3119
- var CONNECTION_ERROR_MESSAGE_RE = /connection (terminated|ended|closed|destroyed|reset|refused|not open)|Connection terminated unexpectedly|Client has encountered a connection error|server closed the connection|Cannot use a pool after calling end|This socket has been ended|connect ECONNRESET/i;
3143
+ var CONNECTION_ERROR_MESSAGE_RE = /connection (terminated|ended|closed|destroyed|reset|refused|not open)|Connection (terminated|ended) unexpectedly|Client has encountered a connection error|server closed the connection|Cannot use a pool after calling end|This socket has been ended|connect ECONNRESET/i;
3120
3144
  var Db = class _Db {
3121
3145
  static async init(context, options = {}) {
3122
3146
  const buildConfig = async () => {
@@ -3384,6 +3408,10 @@ var Db = class _Db {
3384
3408
  this.isConnected = false;
3385
3409
  this.queriesLog = [];
3386
3410
  this._reconnectPromise = null;
3411
+ this._closed = false;
3412
+ this._liveKnex = /* @__PURE__ */ new Set();
3413
+ this._reconnectCooldownUntil = 0;
3414
+ this._reconnectAcquireTimeoutMs = null;
3387
3415
  this.config = {
3388
3416
  testConnection: true,
3389
3417
  profile: false,
@@ -3468,6 +3496,9 @@ var Db = class _Db {
3468
3496
  return null;
3469
3497
  }
3470
3498
  async connect() {
3499
+ if (this._closed) {
3500
+ throw new ParamError("Db: Connection closed");
3501
+ }
3471
3502
  if (this.isConnected && this.knexInstance) {
3472
3503
  this.logger.warn?.("[Db] Already connected");
3473
3504
  return;
@@ -3483,22 +3514,46 @@ var Db = class _Db {
3483
3514
  connectionString: this.config.connectionString,
3484
3515
  family: 4
3485
3516
  };
3517
+ const acquireTimeout = this._reconnectAcquireTimeoutMs ?? this.config.acquireConnectionTimeout;
3486
3518
  this.knexInstance = knex({
3487
3519
  client,
3488
3520
  connection: connectionConfig,
3489
3521
  pool: this.config.pool,
3490
- acquireConnectionTimeout: this.config.acquireConnectionTimeout,
3522
+ acquireConnectionTimeout: acquireTimeout,
3491
3523
  ...this.config.ssl && { ssl: this.config.ssl }
3492
3524
  });
3525
+ this._liveKnex.add(this.knexInstance);
3526
+ this.knexInstance.on?.("error", (err) => {
3527
+ if (this._closed) return;
3528
+ this.logger.warn?.(
3529
+ `[Db] Connection error (${this.getErrorMessage(err)})`
3530
+ );
3531
+ });
3532
+ if (this._closed) {
3533
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3534
+ this.knexInstance = null;
3535
+ throw new ParamError("Db: Connection closed");
3536
+ }
3493
3537
  if (this.config.profile) {
3494
3538
  this.attachProfiler();
3495
3539
  }
3496
3540
  if (this.config.testConnection) {
3497
3541
  await this.testConnection();
3498
3542
  }
3543
+ if (this._closed) {
3544
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3545
+ this.knexInstance = null;
3546
+ throw new ParamError("Db: Connection closed");
3547
+ }
3499
3548
  this.isConnected = true;
3500
3549
  this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
3501
3550
  } catch (error) {
3551
+ const failed = this.knexInstance;
3552
+ this.knexInstance = null;
3553
+ this.isConnected = false;
3554
+ if (failed) {
3555
+ await this._destroyKnex(failed, "connect failed");
3556
+ }
3502
3557
  if (error instanceof ParamError) {
3503
3558
  throw error;
3504
3559
  }
@@ -3506,20 +3561,42 @@ var Db = class _Db {
3506
3561
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
3507
3562
  }
3508
3563
  }
3509
- async disconnect() {
3510
- if (!this.knexInstance) {
3511
- return;
3512
- }
3564
+ /**
3565
+ * Destroy a knex pool without hanging exit on stuck TCP sockets (ETIMEDOUT).
3566
+ * @param {import("knex").Knex | null | undefined} knexInst
3567
+ * @param {string} [reason]
3568
+ * @param {number} [timeoutMs]
3569
+ */
3570
+ async _destroyKnex(knexInst, reason = "destroy", timeoutMs = 3e3) {
3571
+ if (!knexInst || typeof knexInst.destroy !== "function") return;
3572
+ this._liveKnex.delete(knexInst);
3513
3573
  try {
3514
- await this.knexInstance.destroy();
3515
- this.knexInstance = null;
3516
- this.isConnected = false;
3517
- this.queriesLog = [];
3518
- this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3574
+ await Promise.race([
3575
+ knexInst.destroy(),
3576
+ new Promise((_, reject) => {
3577
+ const t = setTimeout(
3578
+ () => reject(new Error(`Db: ${reason} timed out after ${timeoutMs}ms`)),
3579
+ timeoutMs
3580
+ );
3581
+ t.unref?.();
3582
+ })
3583
+ ]);
3519
3584
  } catch (error) {
3520
- const errorMsg = this.getErrorMessage(error);
3521
- this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
3522
- throw error;
3585
+ this.logger.debug?.(
3586
+ `[Db] ${reason}: ${this.getErrorMessage(error)}`
3587
+ );
3588
+ }
3589
+ }
3590
+ async disconnect() {
3591
+ this._closed = true;
3592
+ this.isConnected = false;
3593
+ this.knexInstance = null;
3594
+ this.queriesLog = [];
3595
+ const all = [...this._liveKnex];
3596
+ this._liveKnex.clear();
3597
+ await Promise.all(all.map((inst) => this._destroyKnex(inst, "disconnect")));
3598
+ if (all.length > 0) {
3599
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3523
3600
  }
3524
3601
  }
3525
3602
  /**
@@ -3542,27 +3619,39 @@ var Db = class _Db {
3542
3619
  }
3543
3620
  /**
3544
3621
  * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
3622
+ * No-ops once disconnect() has closed the handle.
3545
3623
  */
3546
3624
  async reconnect() {
3625
+ if (this._closed) {
3626
+ return;
3627
+ }
3628
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3629
+ return;
3630
+ }
3547
3631
  if (this._reconnectPromise) {
3548
3632
  await this._reconnectPromise;
3549
3633
  return;
3550
3634
  }
3551
3635
  this._reconnectPromise = (async () => {
3636
+ if (this._closed) return;
3552
3637
  const old = this.knexInstance;
3553
3638
  this.isConnected = false;
3554
3639
  this.knexInstance = null;
3555
3640
  this.queriesLog = [];
3556
3641
  if (old) {
3557
- try {
3558
- await old.destroy();
3559
- } catch (error) {
3560
- this.logger.debug?.(
3561
- `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
3562
- );
3563
- }
3642
+ await this._destroyKnex(old, "destroy during reconnect");
3643
+ }
3644
+ if (this._closed) return;
3645
+ this._reconnectAcquireTimeoutMs = 3e3;
3646
+ try {
3647
+ await this.connect();
3648
+ this._reconnectCooldownUntil = 0;
3649
+ } catch (error) {
3650
+ this._reconnectCooldownUntil = Date.now() + 5e3;
3651
+ throw error;
3652
+ } finally {
3653
+ this._reconnectAcquireTimeoutMs = null;
3564
3654
  }
3565
- await this.connect();
3566
3655
  })();
3567
3656
  try {
3568
3657
  await this._reconnectPromise;
@@ -3571,10 +3660,19 @@ var Db = class _Db {
3571
3660
  }
3572
3661
  }
3573
3662
  async reconnectAfterConnectionError(error) {
3663
+ if (this._closed) {
3664
+ return;
3665
+ }
3666
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3667
+ return;
3668
+ }
3574
3669
  this.logger.warn?.(
3575
3670
  `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
3576
3671
  );
3577
- await this.reconnect();
3672
+ try {
3673
+ await this.reconnect();
3674
+ } catch {
3675
+ }
3578
3676
  }
3579
3677
  /**
3580
3678
  * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
@@ -3587,10 +3685,13 @@ var Db = class _Db {
3587
3685
  return await fn();
3588
3686
  } catch (error) {
3589
3687
  lastError = error;
3590
- if (!this.isConnectionError(error) || attempt >= retries) {
3688
+ if (this._closed || !this.isConnectionError(error) || attempt >= retries) {
3591
3689
  throw error;
3592
3690
  }
3593
3691
  await this.reconnectAfterConnectionError(error);
3692
+ if (this._closed) {
3693
+ throw error;
3694
+ }
3594
3695
  }
3595
3696
  }
3596
3697
  throw lastError;
@@ -3614,10 +3715,13 @@ var Db = class _Db {
3614
3715
  try {
3615
3716
  return await protoThen.call(builder);
3616
3717
  } catch (error) {
3617
- if (!inst.isConnectionError(error)) {
3718
+ if (inst._closed || !inst.isConnectionError(error)) {
3618
3719
  throw error;
3619
3720
  }
3620
3721
  await inst.reconnectAfterConnectionError(error);
3722
+ if (inst._closed || !inst.knexInstance) {
3723
+ throw error;
3724
+ }
3621
3725
  if (typeof builder.clone === "function") {
3622
3726
  const retry = builder.clone();
3623
3727
  retry.client = inst.knexInstance.client;