@xeplr/utils 1.0.1 → 1.0.2

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/index.js CHANGED
@@ -1,9 +1,11 @@
1
- const { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv } = require('./lib/email');
1
+ const { sendEmail, configureEmail, emailConfigFromEnv, smtpFromEnv, configureFromEnv } = require('./lib/email');
2
2
  const { configureLogger, createSession, log, closeSession, getSessionLogs } = require('./lib/logger');
3
3
  const { generateId, formatDbDateTime, mysqlDateTime } = require('./lib/helpers');
4
4
  const { respond, sanitizeError } = require('./lib/response');
5
5
  const cache = require('./lib/cache');
6
6
  const Queue = require('./lib/queue');
7
+ // Which SQL failures are worth trying again — see lib/sql-error.js.
8
+ const { classifySqlError, isRetryableSqlError } = require('./lib/sql-error');
7
9
  const FileUploader = require('./lib/fileUploader');
8
10
  const RateLimiter = require('./lib/rateLimiter');
9
11
  const sms = require('./lib/sms');
@@ -14,6 +16,7 @@ module.exports = {
14
16
  sendEmail,
15
17
  configureEmail,
16
18
  emailConfigFromEnv,
19
+ smtpFromEnv,
17
20
  configureFromEnv,
18
21
 
19
22
  // Logger client
@@ -38,6 +41,10 @@ module.exports = {
38
41
  // Queue
39
42
  Queue,
40
43
 
44
+ // SQL error classification (retry decisions, and the code a log line carries)
45
+ classifySqlError,
46
+ isRetryableSqlError,
47
+
41
48
  // File Uploader
42
49
  FileUploader,
43
50
 
package/lib/email.js CHANGED
@@ -25,7 +25,7 @@ let _deadLetterQueue = null;
25
25
  * Configure the email service.
26
26
  * @param {object} config
27
27
  * @param {string} config.provider - 'smtp' | 'aws' | 'azure' | 'brevo'
28
- * @param {object} [config.smtp] - { host, port, user, pass, from }
28
+ * @param {object} [config.smtp] - { host, port, user, pass, from, secure }
29
29
  * @param {object} [config.aws] - { region, accessKeyId, secretAccessKey, from }
30
30
  * @param {object} [config.azure] - { connectionString, from }
31
31
  * @param {object} [config.brevo] - { apiKey, fromEmail, fromName }
@@ -116,19 +116,62 @@ function buildAttachments(attachments) {
116
116
  }
117
117
 
118
118
  async function sendViaSMTP(to, subject, html, cc, attachments) {
119
- var cfg = getConfig().smtp;
120
- var transporter = loadNodemailer().createTransport({
119
+ var cfg = getConfig().smtp || {};
120
+ var port = _num(cfg.port, 587);
121
+ // 465 is implicit TLS from the first byte; 587 and 25 start in the clear and
122
+ // negotiate STARTTLS. A hardcoded false made every 465 relay fail on the
123
+ // handshake, so infer from the port unless it is stated.
124
+ var secure = cfg.secure === undefined || cfg.secure === null ? port === 465 : !!cfg.secure;
125
+
126
+ // Everything the caller set is forwarded; everything it did not is absent,
127
+ // so nodemailer's defaults stand. Same shape as the email-send action's
128
+ // connection object, so one SMTP server is described identically whether it
129
+ // is configured on a workflow step or in this install's env.
130
+ var transport = _compact({
121
131
  host: cfg.host,
122
- port: cfg.port,
123
- secure: false,
124
- auth: { user: cfg.user, pass: cfg.pass }
132
+ port: port,
133
+ secure: secure,
134
+ requireTLS: cfg.requireTLS,
135
+ ignoreTLS: cfg.ignoreTLS,
136
+ name: cfg.name,
137
+ authMethod: cfg.authMethod,
138
+ connectionTimeout: cfg.connectionTimeout,
139
+ greetingTimeout: cfg.greetingTimeout,
140
+ socketTimeout: cfg.socketTimeout,
141
+ pool: cfg.pool,
142
+ maxConnections: cfg.maxConnections,
143
+ maxMessages: cfg.maxMessages,
144
+ logger: cfg.debug ? undefined : false,
145
+ debug: cfg.debug,
146
+ tls: cfg.tls && Object.keys(cfg.tls).length ? cfg.tls : undefined
125
147
  });
148
+ if (cfg.user || cfg.pass) transport.auth = _compact({ user: cfg.user, pass: cfg.pass });
149
+
150
+ // Unrecognised options last, so a server that needs something this library
151
+ // does not name is still reachable without editing this library. `tls` is
152
+ // merged a level deeper instead of replaced — SMTP_TLS_* and a tls block in
153
+ // SMTP_OPTIONS are both ways of saying the same thing, and dropping one
154
+ // because the other was set would be a silent downgrade.
155
+ if (cfg.options) {
156
+ var extraTls = cfg.options.tls;
157
+ Object.keys(cfg.options).forEach(function (k) {
158
+ if (k !== 'tls') transport[k] = cfg.options[k];
159
+ });
160
+ if (extraTls) transport.tls = Object.assign({}, transport.tls, extraTls);
161
+ }
162
+
163
+ var transporter = loadNodemailer().createTransport(transport);
126
164
 
127
165
  var mailOptions = { from: cfg.from, to, subject, html };
128
166
  if (cc && cc.length) mailOptions.cc = cc;
129
167
  if (attachments && attachments.length) mailOptions.attachments = buildAttachments(attachments);
130
168
 
131
- await transporter.sendMail(mailOptions);
169
+ try {
170
+ await transporter.sendMail(mailOptions);
171
+ } finally {
172
+ // A pooled transport holds sockets open and would keep the process alive.
173
+ if (typeof transporter.close === 'function') transporter.close();
174
+ }
132
175
  }
133
176
 
134
177
  async function sendViaAWS(to, subject, html, cc, attachments) {
@@ -315,6 +358,93 @@ async function replayDeadLetters() {
315
358
  return items.length;
316
359
  }
317
360
 
361
+ // ── SMTP from env ───────────────────────────────────────────────────────
362
+ //
363
+ // The same knobs the email-send ACTION already exposes on its connection
364
+ // object (see @xeplr/actions lib/drivers/email/smtp.js), read from env instead
365
+ // of from a workflow step. Kept deliberately open-ended rather than curated
366
+ // per provider: "which SMTP server is this" is not a question this library can
367
+ // answer, and every provider-specific list eventually meets a server that
368
+ // needs one more option. Anything left unset is left OUT of the transport, so
369
+ // nodemailer's own defaults apply.
370
+
371
+ function _num(v, fallback) {
372
+ if (v === undefined || v === null || v === '') return fallback;
373
+ var n = Number(v);
374
+ return isFinite(n) ? n : fallback;
375
+ }
376
+
377
+ function _bool(v, fallback) {
378
+ if (v === undefined || v === null || v === '') return fallback;
379
+ var t = String(v).trim().toLowerCase();
380
+ if (['1', 'true', 'yes', 'on'].indexOf(t) !== -1) return true;
381
+ if (['0', 'false', 'no', 'off'].indexOf(t) !== -1) return false;
382
+ return fallback;
383
+ }
384
+
385
+ function _compact(obj) {
386
+ var out = {};
387
+ Object.keys(obj || {}).forEach(function (k) {
388
+ if (obj[k] !== undefined) out[k] = obj[k];
389
+ });
390
+ return out;
391
+ }
392
+
393
+ function smtpFromEnv() {
394
+ var env = process.env;
395
+
396
+ var tls = _compact({
397
+ rejectUnauthorized: _bool(env.SMTP_TLS_REJECT_UNAUTHORIZED, undefined),
398
+ servername: env.SMTP_TLS_SERVERNAME || undefined
399
+ });
400
+
401
+ return _compact({
402
+ host: env.SMTP_HOST,
403
+ port: _num(env.SMTP_PORT, 587),
404
+ user: env.SMTP_USER,
405
+ pass: env.SMTP_PASS,
406
+ from: env.SMTP_FROM,
407
+ // Undefined unless stated, so sendViaSMTP infers it from the port.
408
+ secure: _bool(env.SMTP_SECURE, undefined),
409
+ requireTLS: _bool(env.SMTP_REQUIRE_TLS, undefined),
410
+ ignoreTLS: _bool(env.SMTP_IGNORE_TLS, undefined),
411
+ name: env.SMTP_NAME || undefined, // EHLO/HELO — some servers are picky
412
+ authMethod: env.SMTP_AUTH_METHOD || undefined,
413
+ connectionTimeout: _num(env.SMTP_CONNECTION_TIMEOUT, undefined),
414
+ greetingTimeout: _num(env.SMTP_GREETING_TIMEOUT, undefined),
415
+ socketTimeout: _num(env.SMTP_SOCKET_TIMEOUT, undefined),
416
+ pool: _bool(env.SMTP_POOL, undefined),
417
+ maxConnections: _num(env.SMTP_MAX_CONNECTIONS, undefined),
418
+ maxMessages: _num(env.SMTP_MAX_MESSAGES, undefined),
419
+ debug: _bool(env.SMTP_DEBUG, undefined),
420
+ tls: Object.keys(tls).length ? tls : undefined,
421
+ // THE ESCAPE HATCH. Named options cover the common ground; this carries
422
+ // anything a particular server needs that this library has never heard of
423
+ // (dkim, proxy, tls.ciphers, a vendor's own flag). Merged last, so it can
424
+ // also override any of the above.
425
+ options: _smtpOptionsFromEnv(env.SMTP_OPTIONS)
426
+ });
427
+ }
428
+
429
+ // Invalid JSON THROWS rather than being skipped. A typo here would otherwise
430
+ // drop the one option the server actually needed and fail later as a refused
431
+ // connection or, worse, an unencrypted send that looked fine.
432
+ function _smtpOptionsFromEnv(raw) {
433
+ if (raw === undefined || raw === null || String(raw).trim() === '') return undefined;
434
+ var parsed;
435
+ try {
436
+ parsed = JSON.parse(raw);
437
+ } catch (err) {
438
+ throw new Error('SMTP_OPTIONS is not valid JSON: ' + err.message +
439
+ ' — expected a JSON object, e.g. {"dkim":{"domainName":"x.com"}}');
440
+ }
441
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
442
+ throw new Error('SMTP_OPTIONS must be a JSON OBJECT of nodemailer transport options, got ' +
443
+ (Array.isArray(parsed) ? 'an array' : typeof parsed) + '.');
444
+ }
445
+ return parsed;
446
+ }
447
+
318
448
  // Build an email config from env (EMAIL_PROVIDER + provider vars). Pure — returns
319
449
  // the config or null if no provider is set. The single source for env→email config.
320
450
  function emailConfigFromEnv() {
@@ -329,10 +459,7 @@ function emailConfigFromEnv() {
329
459
  fromName: process.env.BREVO_FROM_NAME
330
460
  };
331
461
  } else if (provider === 'smtp') {
332
- config.smtp = {
333
- host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
334
- user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM
335
- };
462
+ config.smtp = smtpFromEnv();
336
463
  }
337
464
  // aws / azure read their own vars here when those providers are used.
338
465
  return config;
@@ -346,4 +473,4 @@ function configureFromEnv() {
346
473
  return true;
347
474
  }
348
475
 
349
- module.exports = { sendEmail, configureEmail, emailConfigFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
476
+ module.exports = { sendEmail, configureEmail, emailConfigFromEnv, smtpFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
package/lib/queue.js CHANGED
@@ -157,6 +157,8 @@ class Queue {
157
157
  }
158
158
  }
159
159
 
160
+ var { classifySqlError } = require('./sql-error');
161
+
160
162
  // ─────────────────────────────────────────────────────────────────────────
161
163
  // SqlQueue — in-memory concurrent worker pool for SQL execution.
162
164
  //
@@ -263,7 +265,8 @@ class SqlQueue {
263
265
  if (it.movementId === movementId) {
264
266
  this._byteTotal -= it.bytes;
265
267
  state.queued--;
266
- state.dropped++;
268
+ state.dropped += rowsOf(it);
269
+ state.statementsDropped++;
267
270
  } else {
268
271
  kept.push(it);
269
272
  }
@@ -275,6 +278,31 @@ class SqlQueue {
275
278
  catch (e) { /* swallow — never let a hook block */ }
276
279
  }
277
280
 
281
+ // Drop everything queued for a movement that has already aborted.
282
+ //
283
+ // Same bookkeeping as abort()'s own sweep — these rows are lost either way,
284
+ // and a count that omits them is the same lie by a slower route.
285
+ _purgeAborted() {
286
+ if (!this._items.length) return;
287
+ var kept = [];
288
+ for (var i = 0; i < this._items.length; i++) {
289
+ var it = this._items[i];
290
+ var st = this._stateFor(it.movementId);
291
+ if (st.aborted) {
292
+ this._byteTotal -= it.bytes;
293
+ st.queued--;
294
+ st.dropped += rowsOf(it);
295
+ st.statementsDropped++;
296
+ } else {
297
+ kept.push(it);
298
+ }
299
+ }
300
+ if (kept.length !== this._items.length) {
301
+ this._items = kept;
302
+ this._releaseBackpressure();
303
+ }
304
+ }
305
+
278
306
  // Stats: per-movement if id passed, else global.
279
307
  stats(movementId) {
280
308
  if (movementId) {
@@ -302,7 +330,23 @@ class SqlQueue {
302
330
  _stateFor(movementId) {
303
331
  var s = this._movements.get(movementId);
304
332
  if (!s) {
305
- s = { movementId: movementId, queued: 0, inFlight: 0, completed: 0, dropped: 0, aborted: false, abortReason: null, consecutiveDrops: 0 };
333
+ // `completed` and `dropped` count ROWS, not statements.
334
+ //
335
+ // They counted statements, and one statement is a multi-row INSERT — so a
336
+ // 12,000-row movement in three batches reported "completed: 3". That
337
+ // number is surfaced to people as ROWS LOADED (import_meta.completed, and
338
+ // the import history page reading it), which meant every import in the
339
+ // product has been reporting its batch count as a row count.
340
+ //
341
+ // Every queued item already carries meta.rowCount; the counters just were
342
+ // not using it. Statements are counted separately for anyone who wants
343
+ // them — see statements/statementsDropped.
344
+ s = {
345
+ movementId: movementId, queued: 0, inFlight: 0,
346
+ completed: 0, dropped: 0,
347
+ statements: 0, statementsDropped: 0,
348
+ aborted: false, abortReason: null, consecutiveDrops: 0
349
+ };
306
350
  this._movements.set(movementId, s);
307
351
  }
308
352
  return s;
@@ -321,7 +365,25 @@ class SqlQueue {
321
365
  var m = this._items[i].movementId;
322
366
  if (!this._stateFor(m).aborted) { idx = i; break; }
323
367
  }
324
- if (idx < 0) { await sleep(25); continue; }
368
+ // NOTHING LEFT THAT IS ALLOWED TO RUN — every queued item belongs to a
369
+ // movement that has aborted. They can never be picked up (this loop
370
+ // skips aborted movements) and nothing else removes them, so without
371
+ // this they sit in _items forever and drain() waits on a queue that
372
+ // will never empty: a movement that has already finished its work and
373
+ // hangs, with no summary line ever written.
374
+ //
375
+ // Reachable because abort() clears what is queued AT THAT MOMENT, while
376
+ // items keep arriving after it — a bisect re-enqueueing children of an
377
+ // in-flight failure, or a still-running spool adding the next batch.
378
+ // Masked until deterministic errors stopped being retried: the retry
379
+ // backoff used to hold those children in _pendingRetries (which DOES
380
+ // check aborted) rather than in the queue.
381
+ if (idx < 0) {
382
+ this._purgeAborted();
383
+ await sleep(25);
384
+ this._maybeSignalIdle();
385
+ continue;
386
+ }
325
387
 
326
388
  var item = this._items.splice(idx, 1)[0];
327
389
  this._byteTotal -= item.bytes;
@@ -345,7 +407,8 @@ class SqlQueue {
345
407
  try { conn = await this._resolveConnection(item.connection); }
346
408
  catch (err) {
347
409
  // Connection resolution failure = fatal for the movement.
348
- state.dropped++;
410
+ state.dropped += rowsOf(item);
411
+ state.statementsDropped++;
349
412
  this.abort(item.movementId, 'connection_resolve_failed: ' + err.message);
350
413
  return;
351
414
  }
@@ -353,7 +416,8 @@ class SqlQueue {
353
416
  item.attempt++;
354
417
  try {
355
418
  await this.executor(item, conn);
356
- state.completed++;
419
+ state.completed += rowsOf(item);
420
+ state.statements++;
357
421
  state.consecutiveDrops = 0;
358
422
  return;
359
423
  } catch (err) {
@@ -380,6 +444,15 @@ class SqlQueue {
380
444
  }
381
445
 
382
446
  if (decision.decision === 'bisect' && item.meta && Array.isArray(item.meta.rows) && item.meta.rows.length > 1) {
447
+ // Not into an aborted movement. Splitting a batch to find out which
448
+ // half is bad is only worth doing if the halves can still run, and
449
+ // they cannot — the movement has given up. Counted as dropped,
450
+ // because that is what happens to them.
451
+ if (state.aborted) {
452
+ state.dropped += rowsOf(item);
453
+ state.statementsDropped++;
454
+ return;
455
+ }
383
456
  var mid = Math.floor(item.meta.rows.length / 2);
384
457
  var left = cloneItemWithRows(item, item.meta.rows.slice(0, mid));
385
458
  var right = cloneItemWithRows(item, item.meta.rows.slice(mid));
@@ -394,14 +467,22 @@ class SqlQueue {
394
467
  }
395
468
 
396
469
  // 'error-table' | 'drop' | anything else past max attempts → drop
397
- state.dropped++;
470
+ state.dropped += rowsOf(item);
471
+ state.statementsDropped++;
398
472
  state.consecutiveDrops++;
399
473
  try {
400
474
  this.onErrorTable({
401
475
  item: item,
402
476
  error: err,
403
477
  rowNum: item.meta && item.meta.rowNum,
404
- reason: decision.reason || err.message
478
+ reason: decision.reason || err.message,
479
+ // The database's OWN identifier for what went wrong ('23502', 515,
480
+ // 'ER_BAD_NULL_ERROR') and whether it was ever worth retrying —
481
+ // facts a log analyzer can group by, where the message is prose
482
+ // that varies by server version and locale.
483
+ errorCode: decision.errorCode != null ? decision.errorCode : null,
484
+ errorKind: decision.errorKind || null,
485
+ attempts: item.attempt
405
486
  });
406
487
  } catch (_) { /* swallow */ }
407
488
  if (state.consecutiveDrops >= this.maxConsecutiveDrops) {
@@ -446,11 +527,40 @@ class SqlQueue {
446
527
  function defaultErrorExecutor(ctx) {
447
528
  // Default: retry until final attempt, then route the row (or the whole
448
529
  // batch if not bisectable) to the error table.
449
- if (!ctx.isFinal) return { decision: 'retry' };
450
- if (ctx.item.meta && Array.isArray(ctx.item.meta.rows) && ctx.item.meta.rows.length > 1) {
451
- return { decision: 'bisect' };
530
+ //
531
+ // EXCEPT when the database has already given its final answer. A NOT NULL
532
+ // violation, a failed cast or a missing column fails identically however
533
+ // many times it is sent, so retrying one buys nothing and costs the whole
534
+ // backoff — 65 seconds by default, per item, and a bisect resets its
535
+ // children to attempt 0 so that multiplies down the tree. Measured on a
536
+ // 1000-row movement into a NOT NULL column: 105 seconds, nearly all of it
537
+ // asleep, for a verdict available immediately.
538
+ //
539
+ // Straight to bisect instead, which is the fast path to the SAME outcome —
540
+ // it isolates which rows are bad instead of waiting to find out.
541
+ // classifySqlError treats anything it does not recognise as retryable, so
542
+ // this only ever skips waiting when the error is known to be deterministic.
543
+ var verdict = classifySqlError(ctx.error);
544
+ var bisectable = ctx.item.meta && Array.isArray(ctx.item.meta.rows) && ctx.item.meta.rows.length > 1;
545
+
546
+ if (!verdict.retryable) {
547
+ if (bisectable) return { decision: 'bisect', errorCode: verdict.code, errorKind: verdict.kind };
548
+ return {
549
+ decision: 'error-table',
550
+ reason: ctx.error && ctx.error.message,
551
+ errorCode: verdict.code,
552
+ errorKind: verdict.kind
553
+ };
452
554
  }
453
- return { decision: 'error-table', reason: ctx.error && ctx.error.message };
555
+
556
+ if (!ctx.isFinal) return { decision: 'retry' };
557
+ if (bisectable) return { decision: 'bisect', errorCode: verdict.code, errorKind: verdict.kind };
558
+ return {
559
+ decision: 'error-table',
560
+ reason: ctx.error && ctx.error.message,
561
+ errorCode: verdict.code,
562
+ errorKind: verdict.kind
563
+ };
454
564
  }
455
565
 
456
566
  function cloneItemWithRows(item, rows) {
@@ -471,6 +581,14 @@ function cloneItemWithRows(item, rows) {
471
581
  return next;
472
582
  }
473
583
 
584
+ // Rows in one queued item. The uploader puts the real count on meta; anything
585
+ // that queues without one counts as a single row, which keeps a caller that
586
+ // enqueues row-at-a-time honest rather than reporting zero.
587
+ function rowsOf(item) {
588
+ var n = item && item.meta && item.meta.rowCount;
589
+ return typeof n === 'number' && n >= 0 ? n : 1;
590
+ }
591
+
474
592
  function approximateBytes(item) {
475
593
  try { return JSON.stringify(item).length; } catch (_) { return 128; }
476
594
  }
@@ -480,3 +598,4 @@ function sleep(ms) { return new Promise(function(res) { setTimeout(res, ms); });
480
598
  module.exports = Queue;
481
599
  module.exports.Queue = Queue;
482
600
  module.exports.SqlQueue = SqlQueue;
601
+ module.exports.classifySqlError = classifySqlError;
@@ -0,0 +1,208 @@
1
+ // WHICH SQL FAILURES ARE WORTH TRYING AGAIN.
2
+ //
3
+ // A retry is a bet that the same statement, sent again, behaves differently.
4
+ // That bet pays off for a dropped connection, a deadlock victim or a server
5
+ // out of connections — and never for a NOT NULL violation, a bad cast or a
6
+ // column that does not exist. Those fail identically every time, so retrying
7
+ // one costs the backoff (5s + 15s + 45s by default) and then drops the rows
8
+ // anyway.
9
+ //
10
+ // Measured on a real 1000-row movement into a NOT NULL column: 105 seconds,
11
+ // almost all of it asleep, for a verdict available on the first attempt. Worse
12
+ // than slow — a bisect restarts its children at attempt 0, so the wasted
13
+ // backoff multiplies down the bisect tree while a person watches a movement
14
+ // that looks hung.
15
+ //
16
+ // ── why one function serves postgres, mysql and mssql ────────────────────
17
+ //
18
+ // The three drivers throw their native error untouched (see each driver's
19
+ // query()), and each dialect stamps its identity on a DIFFERENT property:
20
+ //
21
+ // postgres err.code 5-char SQLSTATE — '23502', '40001'
22
+ // mysql err.code ER_* name, plus err.errno — 'ER_BAD_NULL_ERROR', 1048
23
+ // mssql err.number integer — 515, 1205
24
+ //
25
+ // So the shapes are self-identifying and no dbType has to be threaded through
26
+ // the queue to read them. A caller that HAS the dbType is not asked for it,
27
+ // which keeps this usable from the queue's default error executor — the one
28
+ // place that decides retries, and the one place with no idea what it is
29
+ // talking to.
30
+ //
31
+ // ── unknown means retry ─────────────────────────────────────────────────
32
+ //
33
+ // An unrecognised error is treated as transient, i.e. the behavior before this
34
+ // file existed. Wrongly retrying something deterministic costs time; wrongly
35
+ // dropping something transient loses rows. The default belongs on the side
36
+ // that cannot lose data.
37
+
38
+ // ── postgres ────────────────────────────────────────────────────────────
39
+ // SQLSTATE is classified by its two-character class where the whole class
40
+ // agrees, which is most of them — listing 200 individual codes would go stale
41
+ // against the next server release for no gain.
42
+ //
43
+ // 22 data exception invalid text, numeric overflow, bad date
44
+ // 23 integrity constraint not null, unique, foreign key, check
45
+ // 42 syntax error / access no such column, no such table, no privilege
46
+ // 3F invalid schema name
47
+ // 0A feature not supported
48
+ var PG_DETERMINISTIC_CLASSES = ['22', '23', '42', '3F', '0A'];
49
+ // 08 connection exception server closed the connection, failure
50
+ // 40 transaction rollback serialization failure, deadlock detected
51
+ // 53 insufficient resources too many connections, out of memory/disk
52
+ // 57 operator intervention shutdown, cannot connect now, admin cancel
53
+ // 58 system error io error
54
+ var PG_TRANSIENT_CLASSES = ['08', '40', '53', '57', '58'];
55
+
56
+ // ── mysql ───────────────────────────────────────────────────────────────
57
+ // By errno, because err.code (the ER_ name) is absent on some client
58
+ // versions while errno is always there. Names kept alongside for reading.
59
+ var MYSQL_DETERMINISTIC = {
60
+ 1048: 'ER_BAD_NULL_ERROR', // column cannot be null
61
+ 1062: 'ER_DUP_ENTRY', // duplicate key
62
+ 1064: 'ER_PARSE_ERROR',
63
+ 1136: 'ER_WRONG_VALUE_COUNT_ON_ROW',
64
+ 1146: 'ER_NO_SUCH_TABLE',
65
+ 1054: 'ER_BAD_FIELD_ERROR', // unknown column
66
+ 1264: 'ER_WARN_DATA_OUT_OF_RANGE',
67
+ 1265: 'WARN_DATA_TRUNCATED',
68
+ 1292: 'ER_TRUNCATED_WRONG_VALUE', // bad date / bad cast
69
+ 1366: 'ER_TRUNCATED_WRONG_VALUE_FOR_FIELD',
70
+ 1406: 'ER_DATA_TOO_LONG',
71
+ 1451: 'ER_ROW_IS_REFERENCED_2',
72
+ 1452: 'ER_NO_REFERENCED_ROW_2', // foreign key
73
+ 3819: 'ER_CHECK_CONSTRAINT_VIOLATED',
74
+ 1364: 'ER_NO_DEFAULT_FOR_FIELD',
75
+ 1305: 'ER_SP_DOES_NOT_EXIST'
76
+ };
77
+ var MYSQL_TRANSIENT = {
78
+ 1040: 'ER_CON_COUNT_ERROR', // too many connections
79
+ 1203: 'ER_TOO_MANY_USER_CONNECTIONS',
80
+ 1205: 'ER_LOCK_WAIT_TIMEOUT',
81
+ 1213: 'ER_LOCK_DEADLOCK',
82
+ 1290: 'ER_OPTION_PREVENTS_STATEMENT', // read-only, usually a failover
83
+ 1053: 'ER_SERVER_SHUTDOWN',
84
+ 2002: 'CONNECTION_REFUSED',
85
+ 2003: 'CANT_CONNECT',
86
+ 2006: 'SERVER_GONE_AWAY',
87
+ 2013: 'LOST_CONNECTION'
88
+ };
89
+ // Client-side codes carry no errno at all — a socket that died before the
90
+ // server ever answered.
91
+ var MYSQL_TRANSIENT_CODES = [
92
+ 'PROTOCOL_CONNECTION_LOST', 'PROTOCOL_SEQUENCE_TIMEOUT', 'PROTOCOL_ENQUEUE_AFTER_QUIT',
93
+ 'POOL_CLOSED', 'POOL_CONNLIMIT'
94
+ ];
95
+
96
+ // ── mssql ───────────────────────────────────────────────────────────────
97
+ var MSSQL_DETERMINISTIC = {
98
+ 515: 'cannot insert NULL',
99
+ 547: 'foreign key / check constraint',
100
+ 2601: 'duplicate key in unique index',
101
+ 2627: 'unique constraint violation',
102
+ 245: 'conversion failed',
103
+ 8114: 'error converting data type',
104
+ 8152: 'string or binary data would be truncated',
105
+ 2628: 'string or binary data would be truncated (verbose)',
106
+ 207: 'invalid column name',
107
+ 208: 'invalid object name',
108
+ 102: 'incorrect syntax',
109
+ 220: 'arithmetic overflow',
110
+ 232: 'arithmetic overflow for type',
111
+ 8115: 'arithmetic overflow converting'
112
+ };
113
+ var MSSQL_TRANSIENT = {
114
+ 1205: 'deadlock victim',
115
+ 1222: 'lock request timeout',
116
+ 3960: 'snapshot isolation update conflict',
117
+ 4060: 'cannot open database',
118
+ 40197: 'azure: service error, request processing',
119
+ 40501: 'azure: service busy',
120
+ 40613: 'azure: database unavailable',
121
+ 49918: 'azure: cannot process request, not enough resources',
122
+ 49919: 'azure: cannot process create or update request',
123
+ 49920: 'azure: cannot process request, too many operations',
124
+ 10053: 'transport-level error',
125
+ 10054: 'connection reset by peer',
126
+ 10060: 'connection timeout',
127
+ 233: 'no process on the other end of the pipe',
128
+ 64: 'transport-level error on receive'
129
+ };
130
+
131
+ // Node's own socket-level failures, dialect-independent: the driver never got
132
+ // far enough for the server to have an opinion.
133
+ var NODE_TRANSIENT_CODES = [
134
+ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ESOCKET', 'ESOCKETTIMEOUT',
135
+ 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'EAI_AGAIN',
136
+ 'ECONNCLOSED', 'ETIMEOUT', 'ENOTOPEN'
137
+ ];
138
+
139
+ /**
140
+ * Classify one database error.
141
+ *
142
+ * @returns {{ retryable: boolean, kind: string, code: string|null, dialect: string|null }}
143
+ * kind is 'transient' | 'deterministic' | 'unknown'. `code` is whatever the
144
+ * dialect stamped on it, as a string, so a log line can carry the database's
145
+ * own identifier rather than a paraphrase of it.
146
+ */
147
+ function classifySqlError(err) {
148
+ var unknown = { retryable: true, kind: 'unknown', code: null, dialect: null };
149
+ if (!err) return unknown;
150
+
151
+ var code = err.code != null ? String(err.code) : null;
152
+
153
+ // Socket-level, before any dialect has spoken.
154
+ if (code && NODE_TRANSIENT_CODES.indexOf(code) !== -1) {
155
+ return { retryable: true, kind: 'transient', code: code, dialect: null };
156
+ }
157
+
158
+ // postgres — SQLSTATE: exactly five characters, digits and capitals.
159
+ if (code && /^[0-9A-Z]{5}$/.test(code)) {
160
+ var cls = code.slice(0, 2);
161
+ if (PG_DETERMINISTIC_CLASSES.indexOf(cls) !== -1) {
162
+ return { retryable: false, kind: 'deterministic', code: code, dialect: 'postgres' };
163
+ }
164
+ if (PG_TRANSIENT_CLASSES.indexOf(cls) !== -1) {
165
+ return { retryable: true, kind: 'transient', code: code, dialect: 'postgres' };
166
+ }
167
+ return { retryable: true, kind: 'unknown', code: code, dialect: 'postgres' };
168
+ }
169
+
170
+ // mysql — errno is the reliable half; the ER_ name is not always present.
171
+ var errno = typeof err.errno === 'number' ? err.errno : null;
172
+ if (errno != null && (MYSQL_DETERMINISTIC[errno] || MYSQL_TRANSIENT[errno])) {
173
+ var mysqlCode = code || MYSQL_DETERMINISTIC[errno] || MYSQL_TRANSIENT[errno];
174
+ return MYSQL_DETERMINISTIC[errno]
175
+ ? { retryable: false, kind: 'deterministic', code: mysqlCode, dialect: 'mysql' }
176
+ : { retryable: true, kind: 'transient', code: mysqlCode, dialect: 'mysql' };
177
+ }
178
+ if (code && MYSQL_TRANSIENT_CODES.indexOf(code) !== -1) {
179
+ return { retryable: true, kind: 'transient', code: code, dialect: 'mysql' };
180
+ }
181
+ // An ER_-named error with an errno this file has never heard of is still
182
+ // mysql, and still unknown — reported as such rather than guessed at.
183
+ if (code && /^ER_/.test(code)) {
184
+ return { retryable: true, kind: 'unknown', code: code, dialect: 'mysql' };
185
+ }
186
+
187
+ // mssql — tedious puts the server's error number on `number`.
188
+ var number = typeof err.number === 'number' ? err.number : null;
189
+ if (number != null) {
190
+ if (MSSQL_DETERMINISTIC[number]) {
191
+ return { retryable: false, kind: 'deterministic', code: String(number), dialect: 'mssql' };
192
+ }
193
+ if (MSSQL_TRANSIENT[number]) {
194
+ return { retryable: true, kind: 'transient', code: String(number), dialect: 'mssql' };
195
+ }
196
+ return { retryable: true, kind: 'unknown', code: String(number), dialect: 'mssql' };
197
+ }
198
+
199
+ return code ? { retryable: true, kind: 'unknown', code: code, dialect: null } : unknown;
200
+ }
201
+
202
+ /** True only when trying the identical statement again could plausibly work. */
203
+ function isRetryableSqlError(err) { return classifySqlError(err).retryable; }
204
+
205
+ module.exports = {
206
+ classifySqlError: classifySqlError,
207
+ isRetryableSqlError: isRetryableSqlError
208
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/utils",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Utility functions: email (SMTP, AWS SES, Azure, Brevo), cache, queue, logging client, and helpers",
5
5
  "main": "index.js",
6
6
  "files": [