@nmakarov/cli-toolkit 0.43.0 → 0.46.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.
package/dist/index.js CHANGED
@@ -2283,23 +2283,18 @@ function defaultFileSynopsisFunction(fileEntry, data) {
2283
2283
  const timestamps = [];
2284
2284
  const statusCounts = {};
2285
2285
  for (const item of data) {
2286
- let ts = null;
2287
- let status = null;
2286
+ if (!item || typeof item !== "object") continue;
2288
2287
  for (const [key, value] of Object.entries(item)) {
2289
2288
  const k = key.toLowerCase();
2290
- if (k === "modificationtimestamp") {
2291
- ts = new Date(value).getTime();
2289
+ if (k.endsWith("modificationtimestamp") && value != null && value !== "") {
2290
+ const ts = new Date(value).getTime();
2291
+ if (!Number.isNaN(ts)) timestamps.push(ts);
2292
2292
  }
2293
- if (k === "standardstatus") {
2294
- status = value;
2293
+ if (k === "standardstatus" && value != null && value !== "") {
2294
+ const status = String(value);
2295
+ statusCounts[status] = (statusCounts[status] || 0) + 1;
2295
2296
  }
2296
2297
  }
2297
- if (ts && !isNaN(ts)) {
2298
- timestamps.push(ts);
2299
- }
2300
- if (status !== null && status !== void 0) {
2301
- statusCounts[status] = (statusCounts[status] || 0) + 1;
2302
- }
2303
2298
  }
2304
2299
  const result = { ...fileEntry };
2305
2300
  if (timestamps.length) {
@@ -2318,27 +2313,38 @@ function defaultVersionSynopsisFunction(metadata) {
2318
2313
  const timestamps = [];
2319
2314
  const statusCounts = {};
2320
2315
  for (const file of metadata.files) {
2321
- if (file.minModificationTimestamp) {
2316
+ if (file?.minModificationTimestamp) {
2322
2317
  const minTs = new Date(file.minModificationTimestamp).getTime();
2323
- if (!isNaN(minTs)) timestamps.push(minTs);
2318
+ if (!Number.isNaN(minTs)) timestamps.push(minTs);
2324
2319
  }
2325
- if (file.maxModificationTimestamp) {
2320
+ if (file?.maxModificationTimestamp) {
2326
2321
  const maxTs = new Date(file.maxModificationTimestamp).getTime();
2327
- if (!isNaN(maxTs)) timestamps.push(maxTs);
2322
+ if (!Number.isNaN(maxTs)) timestamps.push(maxTs);
2328
2323
  }
2329
- if (file.StandardStatuses && typeof file.StandardStatuses === "object") {
2324
+ if (file?.StandardStatuses && typeof file.StandardStatuses === "object") {
2330
2325
  for (const [status, count] of Object.entries(file.StandardStatuses)) {
2331
- statusCounts[status] = (statusCounts[status] || 0) + count;
2326
+ statusCounts[status] = (statusCounts[status] || 0) + Number(count || 0);
2332
2327
  }
2333
2328
  }
2334
2329
  }
2335
2330
  const result = { ...metadata };
2331
+ const synopsis = {
2332
+ ...metadata.synopsis && typeof metadata.synopsis === "object" ? metadata.synopsis : {}
2333
+ };
2336
2334
  if (timestamps.length) {
2337
- result.minModificationTimestamp = new Date(Math.min(...timestamps)).toISOString();
2338
- result.maxModificationTimestamp = new Date(Math.max(...timestamps)).toISOString();
2335
+ const minIso = new Date(Math.min(...timestamps)).toISOString();
2336
+ const maxIso = new Date(Math.max(...timestamps)).toISOString();
2337
+ result.minModificationTimestamp = minIso;
2338
+ result.maxModificationTimestamp = maxIso;
2339
+ synopsis.minModificationTimestamp = minIso;
2340
+ synopsis.maxModificationTimestamp = maxIso;
2339
2341
  }
2340
2342
  if (Object.keys(statusCounts).length > 0) {
2341
2343
  result.StandardStatuses = statusCounts;
2344
+ synopsis.StandardStatuses = statusCounts;
2345
+ }
2346
+ if (Object.keys(synopsis).length) {
2347
+ result.synopsis = synopsis;
2342
2348
  }
2343
2349
  return result;
2344
2350
  }
@@ -3472,10 +3478,35 @@ async function ensureSchemaEverywhere(dbs, spec, options = {}) {
3472
3478
  // src/db/index.js
3473
3479
  var KNEX_DEFAULTS = {
3474
3480
  testConnection: true,
3475
- pool: { min: 2, max: 10 },
3481
+ // min: 0 avoids holding idle sockets that go stale during long-running CLIs
3482
+ pool: { min: 0, max: 10, idleTimeoutMillis: 3e4 },
3476
3483
  acquireConnectionTimeout: 1e4,
3477
3484
  ssl: { rejectUnauthorized: false }
3478
3485
  };
3486
+ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3487
+ "ECONNRESET",
3488
+ "ECONNREFUSED",
3489
+ "EPIPE",
3490
+ "ETIMEDOUT",
3491
+ "ENOTFOUND",
3492
+ "EHOSTUNREACH",
3493
+ "ENETUNREACH",
3494
+ "ECONNABORTED",
3495
+ "CONNECTION_ENDED",
3496
+ "CONNECTION_CLOSED",
3497
+ // PostgreSQL SQLSTATE class 08xxx (connection exception) + admin shutdowns
3498
+ "08000",
3499
+ "08001",
3500
+ "08003",
3501
+ "08004",
3502
+ "08006",
3503
+ "08007",
3504
+ "08P01",
3505
+ "57P01",
3506
+ "57P02",
3507
+ "57P03"
3508
+ ]);
3509
+ 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|Timeout acquiring a connection/i;
3479
3510
  var Db = class _Db {
3480
3511
  static async init(context, options = {}) {
3481
3512
  const buildConfig = async () => {
@@ -3723,10 +3754,11 @@ var Db = class _Db {
3723
3754
  this.knexInstance = null;
3724
3755
  this.isConnected = false;
3725
3756
  this.queriesLog = [];
3757
+ this._reconnectPromise = null;
3726
3758
  this.config = {
3727
3759
  testConnection: true,
3728
3760
  profile: false,
3729
- pool: { min: 2, max: 10 },
3761
+ pool: { min: 0, max: 10, idleTimeoutMillis: 3e4 },
3730
3762
  acquireConnectionTimeout: 1e4,
3731
3763
  ssl: { rejectUnauthorized: false },
3732
3764
  logger: console,
@@ -3744,7 +3776,7 @@ var Db = class _Db {
3744
3776
  if (!inst.knexInstance) {
3745
3777
  throw new Error("Db: Not connected. Call connect() first.");
3746
3778
  }
3747
- return inst.knexInstance(...argumentsList);
3779
+ return inst.wrapQueryBuilder(inst.knexInstance(...argumentsList));
3748
3780
  },
3749
3781
  get: (target, prop) => {
3750
3782
  if (prop === "_instance") {
@@ -3754,8 +3786,12 @@ var Db = class _Db {
3754
3786
  const ownMethods = [
3755
3787
  "connect",
3756
3788
  "disconnect",
3789
+ "reconnect",
3757
3790
  "testConnection",
3758
3791
  "tableExists",
3792
+ "raw",
3793
+ "withConnectionRetry",
3794
+ "isConnectionError",
3759
3795
  "getQueryLog",
3760
3796
  "getKnex",
3761
3797
  "isConnectedToDb",
@@ -3775,6 +3811,9 @@ var Db = class _Db {
3775
3811
  if (inst.knexInstance) {
3776
3812
  const knexProp = inst.knexInstance[prop];
3777
3813
  if (typeof knexProp === "function") {
3814
+ if (prop === "raw") {
3815
+ return (...args) => inst.raw(...args);
3816
+ }
3778
3817
  return knexProp.bind(inst.knexInstance);
3779
3818
  }
3780
3819
  return knexProp;
@@ -3854,6 +3893,130 @@ var Db = class _Db {
3854
3893
  throw error;
3855
3894
  }
3856
3895
  }
3896
+ /**
3897
+ * True when the error indicates a dead socket / pool that a fresh connect may fix.
3898
+ * Safe to call as `Db.prototype.isConnectionError(err)` or via a connected handle.
3899
+ */
3900
+ isConnectionError(error) {
3901
+ if (!error) {
3902
+ return false;
3903
+ }
3904
+ if (error instanceof AggregateError && Array.isArray(error.errors)) {
3905
+ return error.errors.some((e) => this.isConnectionError(e));
3906
+ }
3907
+ const code = error.code ?? error.errno;
3908
+ if (code != null && CONNECTION_ERROR_CODES.has(String(code))) {
3909
+ return true;
3910
+ }
3911
+ const msg = error instanceof Error ? error.message : String(error);
3912
+ return CONNECTION_ERROR_MESSAGE_RE.test(msg);
3913
+ }
3914
+ /**
3915
+ * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
3916
+ */
3917
+ async reconnect() {
3918
+ if (this._reconnectPromise) {
3919
+ await this._reconnectPromise;
3920
+ return;
3921
+ }
3922
+ this._reconnectPromise = (async () => {
3923
+ const old = this.knexInstance;
3924
+ this.isConnected = false;
3925
+ this.knexInstance = null;
3926
+ this.queriesLog = [];
3927
+ if (old) {
3928
+ try {
3929
+ await old.destroy();
3930
+ } catch (error) {
3931
+ this.logger.debug?.(
3932
+ `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
3933
+ );
3934
+ }
3935
+ }
3936
+ await this.connect();
3937
+ })();
3938
+ try {
3939
+ await this._reconnectPromise;
3940
+ } finally {
3941
+ this._reconnectPromise = null;
3942
+ }
3943
+ }
3944
+ async reconnectAfterConnectionError(error) {
3945
+ this.logger.warn?.(
3946
+ `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
3947
+ );
3948
+ await this.reconnect();
3949
+ }
3950
+ /**
3951
+ * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
3952
+ * `fn` should look up `this.knexInstance` each call so the retry uses the new client.
3953
+ */
3954
+ async withConnectionRetry(fn, { retries = 1 } = {}) {
3955
+ let lastError;
3956
+ for (let attempt = 0; attempt <= retries; attempt++) {
3957
+ try {
3958
+ return await fn();
3959
+ } catch (error) {
3960
+ lastError = error;
3961
+ if (!this.isConnectionError(error) || attempt >= retries) {
3962
+ throw error;
3963
+ }
3964
+ await this.reconnectAfterConnectionError(error);
3965
+ }
3966
+ }
3967
+ throw lastError;
3968
+ }
3969
+ /**
3970
+ * Wrap a knex QueryBuilder / Raw so that awaiting it retries once after reconnect
3971
+ * when the first attempt dies with a connection error.
3972
+ */
3973
+ wrapQueryBuilder(builder) {
3974
+ if (!builder || typeof builder.then !== "function" || builder.__dbReconnectWrapped) {
3975
+ return builder;
3976
+ }
3977
+ builder.__dbReconnectWrapped = true;
3978
+ const inst = this;
3979
+ const protoThen = Object.getPrototypeOf(builder)?.then;
3980
+ if (typeof protoThen !== "function") {
3981
+ return builder;
3982
+ }
3983
+ builder.then = function(onFulfilled, onRejected) {
3984
+ const run2 = async () => {
3985
+ try {
3986
+ return await protoThen.call(builder);
3987
+ } catch (error) {
3988
+ if (!inst.isConnectionError(error)) {
3989
+ throw error;
3990
+ }
3991
+ await inst.reconnectAfterConnectionError(error);
3992
+ if (typeof builder.clone === "function") {
3993
+ const retry = builder.clone();
3994
+ retry.client = inst.knexInstance.client;
3995
+ return await protoThen.call(retry);
3996
+ }
3997
+ if (typeof builder.toSQL === "function" && inst.knexInstance) {
3998
+ const sql = builder.toSQL();
3999
+ const statements = Array.isArray(sql) ? sql : [sql];
4000
+ let last;
4001
+ for (const stmt of statements) {
4002
+ last = await inst.knexInstance.raw(stmt.sql, stmt.bindings);
4003
+ }
4004
+ return last;
4005
+ }
4006
+ throw error;
4007
+ }
4008
+ };
4009
+ return run2().then(onFulfilled, onRejected);
4010
+ };
4011
+ return builder;
4012
+ }
4013
+ /** knex.raw with auto-reconnect on dead connections. */
4014
+ raw(...args) {
4015
+ if (!this.knexInstance) {
4016
+ throw new Error("Db: Not connected. Call connect() first.");
4017
+ }
4018
+ return this.wrapQueryBuilder(this.knexInstance.raw(...args));
4019
+ }
3857
4020
  getErrorMessage(error) {
3858
4021
  if (error instanceof AggregateError) {
3859
4022
  const errors = error.errors || [];
@@ -4024,7 +4187,9 @@ var Db = class _Db {
4024
4187
  throw new Error("Db: Not connected. Call connect() first.");
4025
4188
  }
4026
4189
  try {
4027
- return await this.knexInstance.schema.hasTable(tableName);
4190
+ return await this.withConnectionRetry(
4191
+ () => this.knexInstance.schema.hasTable(tableName)
4192
+ );
4028
4193
  } catch (error) {
4029
4194
  this.logger.error?.(`[Db] Error checking table existence: ${error.message}`);
4030
4195
  throw error;
@@ -6321,22 +6486,46 @@ async function ensureTaskTables(context, options = {}) {
6321
6486
  }
6322
6487
  }
6323
6488
  const { actions } = await ensureSchema(db, spec, { dryRun, logger: context.logger });
6489
+ const legacyDrops = await dropLegacyTaskNameColumn(db, [tasksTable, historyTable], {
6490
+ dryRun,
6491
+ log,
6492
+ label
6493
+ });
6494
+ const allActions = [...actions, ...legacyDrops];
6324
6495
  if (dryRun) {
6325
- if (actions.length === 0) {
6496
+ if (allActions.length === 0) {
6326
6497
  log.info?.(`[tasks-schema] dryRun \u2014 ${label}: queue "${queueName}" already up to date; no DDL`);
6327
6498
  } else {
6328
6499
  log.info?.(
6329
- `[tasks-schema] dryRun \u2014 ${label}: would run ${actions.length} statement(s) for queue "${queueName}":`
6500
+ `[tasks-schema] dryRun \u2014 ${label}: would run ${allActions.length} statement(s) for queue "${queueName}":`
6330
6501
  );
6331
- for (const s of actions) log.info?.(` - ${s}`);
6502
+ for (const s of allActions) log.info?.(` - ${s}`);
6332
6503
  }
6333
- } else if (actions.length > 0) {
6504
+ } else if (allActions.length > 0) {
6334
6505
  log.info?.(
6335
- `[tasks-schema] ${label}: applied ${actions.length} DDL statement(s) for queue "${queueName}"`
6506
+ `[tasks-schema] ${label}: applied ${allActions.length} DDL statement(s) for queue "${queueName}"`
6336
6507
  );
6337
6508
  }
6338
6509
  }
6339
6510
  }
6511
+ async function dropLegacyTaskNameColumn(db, tableNames, { dryRun, log, label }) {
6512
+ const actions = [];
6513
+ for (const table of tableNames) {
6514
+ if (!await db.tableExists(table).catch(() => false)) continue;
6515
+ const hasTask = await db.schema.hasColumn(table, "task");
6516
+ const hasName = await db.schema.hasColumn(table, "name");
6517
+ if (!hasTask || !hasName) continue;
6518
+ const sql = `ALTER TABLE "${table}" DROP COLUMN IF EXISTS "task"`;
6519
+ actions.push(sql);
6520
+ if (dryRun) {
6521
+ log?.info?.(`[tasks-schema] dryRun \u2014 ${label}: ${sql}`);
6522
+ } else {
6523
+ await db.raw(sql);
6524
+ log?.info?.(`[tasks-schema] ${label}: dropped legacy column ${table}.task`);
6525
+ }
6526
+ }
6527
+ return actions;
6528
+ }
6340
6529
  async function enqueueTask(context, options) {
6341
6530
  const db = getDb(context);
6342
6531
  const queueName = options.queueName ?? "tasks";
@@ -6353,7 +6542,7 @@ async function enqueueTask(context, options) {
6353
6542
  } else if (schedule) {
6354
6543
  nextRunAt = nextTimeMatch(schedule, /* @__PURE__ */ new Date());
6355
6544
  }
6356
- await db(tasksTable).insert({
6545
+ const row = {
6357
6546
  id,
6358
6547
  name,
6359
6548
  params: toJsonColumn(options.params ?? null),
@@ -6367,9 +6556,21 @@ async function enqueueTask(context, options) {
6367
6556
  server_name: options.serverName ?? null,
6368
6557
  status: "idle",
6369
6558
  status_changed_at: db.fn.now()
6370
- });
6559
+ };
6560
+ if (await tableHasLegacyTaskColumn(db, tasksTable)) {
6561
+ row.task = name;
6562
+ }
6563
+ await db(tasksTable).insert(row);
6371
6564
  return id;
6372
6565
  }
6566
+ var legacyTaskColumnCache = /* @__PURE__ */ new Map();
6567
+ async function tableHasLegacyTaskColumn(db, tableName) {
6568
+ const key = `${db?.config?.name ?? "db"}:${tableName}`;
6569
+ if (!legacyTaskColumnCache.has(key)) {
6570
+ legacyTaskColumnCache.set(key, await db.schema.hasColumn(tableName, "task"));
6571
+ }
6572
+ return legacyTaskColumnCache.get(key);
6573
+ }
6373
6574
  async function updateTaskProgress(context, tasksTable, taskId, progress) {
6374
6575
  const db = getDb(context);
6375
6576
  await db(tasksTable).where({ id: taskId }).update({