@xeplr/utils 1.0.0 → 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,17 +1,23 @@
1
- const { sendEmail, configureEmail } = 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');
12
+ const otp = require('./lib/otp');
10
13
 
11
14
  module.exports = {
12
15
  // Email
13
16
  sendEmail,
14
17
  configureEmail,
18
+ emailConfigFromEnv,
19
+ smtpFromEnv,
20
+ configureFromEnv,
15
21
 
16
22
  // Logger client
17
23
  configureLogger,
@@ -35,6 +41,10 @@ module.exports = {
35
41
  // Queue
36
42
  Queue,
37
43
 
44
+ // SQL error classification (retry decisions, and the code a log line carries)
45
+ classifySqlError,
46
+ isRetryableSqlError,
47
+
38
48
  // File Uploader
39
49
  FileUploader,
40
50
 
@@ -42,5 +52,8 @@ module.exports = {
42
52
  RateLimiter,
43
53
 
44
54
  // SMS
45
- sms
55
+ sms,
56
+
57
+ // OTP (uses sms above for delivery)
58
+ otp
46
59
  };
@@ -120,4 +120,63 @@ async function decrypt(encrypted, key) {
120
120
  return new TextDecoder().decode(decrypted);
121
121
  }
122
122
 
123
- module.exports = { encrypt, decrypt };
123
+ /**
124
+ * Same as encrypt(), but returns the salt separately instead of packing it
125
+ * into the payload — for callers that store salt in its own column/field
126
+ * (e.g. to keep the ciphertext opaque even to something inspecting schema,
127
+ * or to swap in a different encryption backend later that takes salt as an
128
+ * explicit input rather than embedding it).
129
+ *
130
+ * @param {string} plaintext
131
+ * @param {string} key - Passphrase
132
+ * @param {string} [salt] - Base64 salt to reuse (e.g. rotating the ciphertext
133
+ * without rotating the salt). Omit to generate a new one.
134
+ * @returns {Promise<{salt: string, ciphertext: string}>} both base64
135
+ */
136
+ async function encryptSplit(plaintext, key, salt) {
137
+ var saltBytes = salt ? fromBase64(salt) : new Uint8Array(16);
138
+ if (!salt) getRandomValues(saltBytes);
139
+ var iv = new Uint8Array(12);
140
+ getRandomValues(iv);
141
+
142
+ var cryptoKey = await deriveKey(key, saltBytes);
143
+ var enc = new TextEncoder();
144
+ var ciphertext = await getSubtle().encrypt(
145
+ { name: 'AES-GCM', iv: iv },
146
+ cryptoKey,
147
+ enc.encode(plaintext)
148
+ );
149
+
150
+ // Pack: iv (12) + ciphertext — salt travels separately.
151
+ var packed = new Uint8Array(12 + ciphertext.byteLength);
152
+ packed.set(iv, 0);
153
+ packed.set(new Uint8Array(ciphertext), 12);
154
+
155
+ return { salt: toBase64(saltBytes), ciphertext: toBase64(packed) };
156
+ }
157
+
158
+ /**
159
+ * Counterpart to encryptSplit() — salt passed in explicitly rather than read
160
+ * from the payload.
161
+ *
162
+ * @param {string} ciphertext - Base64 payload from encryptSplit()
163
+ * @param {string} key - Passphrase
164
+ * @param {string} salt - Base64 salt from encryptSplit()
165
+ * @returns {Promise<string>} Decrypted plaintext
166
+ */
167
+ async function decryptSplit(ciphertext, key, salt) {
168
+ var packed = fromBase64(ciphertext);
169
+ var iv = packed.slice(0, 12);
170
+ var data = packed.slice(12);
171
+
172
+ var cryptoKey = await deriveKey(key, fromBase64(salt));
173
+ var decrypted = await getSubtle().decrypt(
174
+ { name: 'AES-GCM', iv: iv },
175
+ cryptoKey,
176
+ data
177
+ );
178
+
179
+ return new TextDecoder().decode(decrypted);
180
+ }
181
+
182
+ module.exports = { encrypt, decrypt, encryptSplit, decryptSplit };
package/lib/cache.js CHANGED
@@ -1,4 +1,23 @@
1
- const Redis = require('ioredis');
1
+ // `ioredis` is an OPTIONAL peer dependency — nothing in xeplr-utils
2
+ // requires Redis unless you actually use the cache. We lazy-require it so
3
+ // projects that never touch cache/queue/sessions never have to install it.
4
+ // If they DO reach for cache without installing ioredis, throw a message
5
+ // that names the fix, not a raw "Cannot find module" from deep in node.
6
+ let Redis = null;
7
+ function loadRedis() {
8
+ if (Redis) return Redis;
9
+ try { Redis = require('ioredis'); return Redis; }
10
+ catch (e) {
11
+ var err = new Error(
12
+ "@xeplr/utils/lib/cache: 'ioredis' is not installed. Run `npm install ioredis` " +
13
+ "in your project. It's an optional peer dependency — only required if you use " +
14
+ "the cache, queue, or any session-backed feature (xeplr-auth sessions, SSE " +
15
+ "tickets, email queue)."
16
+ );
17
+ err.cause = e;
18
+ throw err;
19
+ }
20
+ }
2
21
 
3
22
  let _client = null;
4
23
  let _config = {};
@@ -38,6 +57,7 @@ function configureCache(config = {}) {
38
57
 
39
58
  function getClient() {
40
59
  if (_client) return _client;
60
+ const RedisCtor = loadRedis();
41
61
 
42
62
  const opts = {
43
63
  host: _config.host || process.env.REDIS_HOST || '127.0.0.1',
@@ -64,7 +84,7 @@ function getClient() {
64
84
  // For extreme throughput, use Cluster mode instead.
65
85
  };
66
86
 
67
- _client = new Redis(opts);
87
+ _client = new RedisCtor(opts);
68
88
 
69
89
  _client.on('error', (err) => {
70
90
  // Silently handle — don't crash the server if Redis is down
package/lib/email.js CHANGED
@@ -1,4 +1,19 @@
1
- const nodemailer = require('nodemailer');
1
+ // `nodemailer` is an optional peer dep — lazy-loaded so projects that
2
+ // don't send email don't have to install it (or its transitive deps).
3
+ let _nodemailer = null;
4
+ function loadNodemailer() {
5
+ if (_nodemailer) return _nodemailer;
6
+ try { _nodemailer = require('nodemailer'); return _nodemailer; }
7
+ catch (e) {
8
+ var err = new Error(
9
+ "@xeplr/utils/lib/email: 'nodemailer' is not installed. Run `npm install nodemailer` " +
10
+ "in your project. It's an optional peer dependency — only required if you send email."
11
+ );
12
+ err.cause = e;
13
+ throw err;
14
+ }
15
+ }
16
+
2
17
  const fs = require('fs');
3
18
  const path = require('path');
4
19
 
@@ -10,7 +25,7 @@ let _deadLetterQueue = null;
10
25
  * Configure the email service.
11
26
  * @param {object} config
12
27
  * @param {string} config.provider - 'smtp' | 'aws' | 'azure' | 'brevo'
13
- * @param {object} [config.smtp] - { host, port, user, pass, from }
28
+ * @param {object} [config.smtp] - { host, port, user, pass, from, secure }
14
29
  * @param {object} [config.aws] - { region, accessKeyId, secretAccessKey, from }
15
30
  * @param {object} [config.azure] - { connectionString, from }
16
31
  * @param {object} [config.brevo] - { apiKey, fromEmail, fromName }
@@ -101,19 +116,62 @@ function buildAttachments(attachments) {
101
116
  }
102
117
 
103
118
  async function sendViaSMTP(to, subject, html, cc, attachments) {
104
- var cfg = getConfig().smtp;
105
- var transporter = nodemailer.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({
106
131
  host: cfg.host,
107
- port: cfg.port,
108
- secure: false,
109
- 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
110
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);
111
164
 
112
165
  var mailOptions = { from: cfg.from, to, subject, html };
113
166
  if (cc && cc.length) mailOptions.cc = cc;
114
167
  if (attachments && attachments.length) mailOptions.attachments = buildAttachments(attachments);
115
168
 
116
- 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
+ }
117
175
  }
118
176
 
119
177
  async function sendViaAWS(to, subject, html, cc, attachments) {
@@ -122,7 +180,7 @@ async function sendViaAWS(to, subject, html, cc, attachments) {
122
180
  // If there are attachments or cc, use SESv2 with raw email via nodemailer
123
181
  if ((attachments && attachments.length) || (cc && cc.length)) {
124
182
  var { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
125
- var transporter = nodemailer.createTransport({ streamTransport: true });
183
+ var transporter = loadNodemailer().createTransport({ streamTransport: true });
126
184
 
127
185
  var mailOptions = { from: cfg.from, to, subject, html };
128
186
  if (cc && cc.length) mailOptions.cc = cc;
@@ -300,4 +358,119 @@ async function replayDeadLetters() {
300
358
  return items.length;
301
359
  }
302
360
 
303
- module.exports = { sendEmail, configureEmail, getDeadLetterQueue, replayDeadLetters };
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
+
448
+ // Build an email config from env (EMAIL_PROVIDER + provider vars). Pure — returns
449
+ // the config or null if no provider is set. The single source for env→email config.
450
+ function emailConfigFromEnv() {
451
+ var provider = process.env.EMAIL_PROVIDER;
452
+ if (!provider) return null;
453
+
454
+ var config = { provider: provider };
455
+ if (provider === 'brevo') {
456
+ config.brevo = {
457
+ apiKey: process.env.BREVO_API_KEY,
458
+ fromEmail: process.env.BREVO_FROM_EMAIL,
459
+ fromName: process.env.BREVO_FROM_NAME
460
+ };
461
+ } else if (provider === 'smtp') {
462
+ config.smtp = smtpFromEnv();
463
+ }
464
+ // aws / azure read their own vars here when those providers are used.
465
+ return config;
466
+ }
467
+
468
+ // Configure the email sender straight from env. Returns true if a provider was set.
469
+ function configureFromEnv() {
470
+ var config = emailConfigFromEnv();
471
+ if (!config) return false;
472
+ configureEmail(config);
473
+ return true;
474
+ }
475
+
476
+ module.exports = { sendEmail, configureEmail, emailConfigFromEnv, smtpFromEnv, configureFromEnv, getDeadLetterQueue, replayDeadLetters };
@@ -1,4 +1,19 @@
1
- const multer = require('multer');
1
+ // `multer` is an optional peer dep — lazy-loaded so projects that
2
+ // don't handle multipart uploads don't have to install it.
3
+ let _multer = null;
4
+ function loadMulter() {
5
+ if (_multer) return _multer;
6
+ try { _multer = require('multer'); return _multer; }
7
+ catch (e) {
8
+ var err = new Error(
9
+ "@xeplr/utils/lib/fileUploader: 'multer' is not installed. Run `npm install multer` " +
10
+ "in your project. It's an optional peer dependency — only required if you handle file uploads."
11
+ );
12
+ err.cause = e;
13
+ throw err;
14
+ }
15
+ }
16
+
2
17
  const path = require('path');
3
18
  const fs = require('fs');
4
19
  const { generateId } = require('./helpers');
@@ -21,6 +36,7 @@ class FileUploader {
21
36
  fs.mkdirSync(this._destination, { recursive: true });
22
37
  }
23
38
 
39
+ var multer = loadMulter();
24
40
  this._multer = multer({
25
41
  storage: multer.diskStorage({
26
42
  destination: (req, file, cb) => {
package/lib/otp.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * OTP (one-time password) — generate, deliver, and verify short-lived codes.
3
+ * Delivery goes through THIS package's own sms module (see lib/sms.js: register
4
+ * a provider there, then otp.js sends through it) — no separate provider
5
+ * registration here, one registry to keep in sync.
6
+ *
7
+ * Codes live in Redis (lib/cache.js) — no DB table, no cleanup job. Expiry is
8
+ * just the Redis key's TTL.
9
+ *
10
+ * Usage:
11
+ * var otp = require('@xeplr/utils').otp;
12
+ * otp.configureOtp({ provider: 'twilio' }); // optional — see defaults below
13
+ *
14
+ * await otp.requestOtp('+91...'); // generates + sends a code
15
+ * await otp.verifyOtp('+91...', '482913'); // { success: true } or { success:false, reason }
16
+ */
17
+ const crypto = require('crypto');
18
+ const cache = require('./cache');
19
+ const sms = require('./sms');
20
+
21
+ var _config = {
22
+ codeLength: 6,
23
+ ttlSeconds: 300, // 5 minutes
24
+ maxAttempts: 5,
25
+ keyPrefix: 'otp:',
26
+ provider: null, // sms provider name (see lib/sms.js register()); falls back to sms.setDefault()
27
+ message: function(code) { return 'Your verification code is ' + code; }
28
+ };
29
+
30
+ /**
31
+ * Configure OTP policy. All optional — defaults above apply otherwise.
32
+ * @param {object} config
33
+ * @param {number} [config.codeLength=6]
34
+ * @param {number} [config.ttlSeconds=300]
35
+ * @param {number} [config.maxAttempts=5]
36
+ * @param {string} [config.provider] - sms provider name registered via sms.register()
37
+ * @param {function} [config.message] - (code) => string, the SMS body
38
+ */
39
+ function configureOtp(config) {
40
+ Object.assign(_config, config || {});
41
+ }
42
+
43
+ function generateCode() {
44
+ var max = Math.pow(10, _config.codeLength);
45
+ var code = crypto.randomInt(0, max);
46
+ return String(code).padStart(_config.codeLength, '0');
47
+ }
48
+
49
+ function otpKey(identifier) {
50
+ return _config.keyPrefix + identifier;
51
+ }
52
+
53
+ /**
54
+ * Generate a code, store it, and send it via the configured SMS provider.
55
+ * @param {string} identifier - phone number (or any unique key) the code is tied to
56
+ * @returns {Promise<{ sent: boolean }>}
57
+ */
58
+ async function requestOtp(identifier) {
59
+ var code = generateCode();
60
+ await cache.set(otpKey(identifier), { code: code, attempts: 0 }, _config.ttlSeconds);
61
+
62
+ var message = _config.message(code);
63
+ var result = _config.provider
64
+ ? await sms.send(_config.provider, { to: identifier, message: message })
65
+ : await sms.send({ to: identifier, message: message });
66
+
67
+ if (!result.success) {
68
+ await cache.del(otpKey(identifier));
69
+ throw new Error('Failed to send OTP: ' + (result.error || 'unknown error'));
70
+ }
71
+ return { sent: true };
72
+ }
73
+
74
+ /**
75
+ * Verify a submitted code against the stored one. Single-use — deleted on
76
+ * success. maxAttempts is the real security bound (not TTL precision): a
77
+ * wrong guess re-arms the same TTL window rather than tracking remaining
78
+ * time, so repeated wrong guesses can extend the window, but never the
79
+ * attempt count past maxAttempts.
80
+ * @param {string} identifier
81
+ * @param {string} code
82
+ * @returns {Promise<{ success: boolean, reason?: 'expired'|'invalid'|'too_many_attempts' }>}
83
+ */
84
+ async function verifyOtp(identifier, code) {
85
+ var key = otpKey(identifier);
86
+ var record = await cache.get(key);
87
+
88
+ if (!record) {
89
+ return { success: false, reason: 'expired' };
90
+ }
91
+
92
+ if (record.attempts >= _config.maxAttempts) {
93
+ await cache.del(key);
94
+ return { success: false, reason: 'too_many_attempts' };
95
+ }
96
+
97
+ if (String(code) !== record.code) {
98
+ record.attempts += 1;
99
+ if (record.attempts >= _config.maxAttempts) {
100
+ await cache.del(key);
101
+ return { success: false, reason: 'too_many_attempts' };
102
+ }
103
+ await cache.set(key, record, _config.ttlSeconds);
104
+ return { success: false, reason: 'invalid' };
105
+ }
106
+
107
+ await cache.del(key);
108
+ return { success: true };
109
+ }
110
+
111
+ module.exports = { configureOtp, requestOtp, verifyOtp };
package/lib/queue.js CHANGED
@@ -157,4 +157,445 @@ class Queue {
157
157
  }
158
158
  }
159
159
 
160
+ var { classifySqlError } = require('./sql-error');
161
+
162
+ // ─────────────────────────────────────────────────────────────────────────
163
+ // SqlQueue — in-memory concurrent worker pool for SQL execution.
164
+ //
165
+ // Movement-scoped: every item carries a `movementId`. Rollback is by
166
+ // movementId. Aborting drops all queued items for one movement without
167
+ // affecting others.
168
+ //
169
+ // Retry / bisect / drop / abort behavior:
170
+ // - Up to `maxAttempts` per item (default 3); errorExecutor decides
171
+ // what to do on each failure ('retry' | 'bisect' | 'error-table' |
172
+ // 'drop' | 'fatal').
173
+ // - 'bisect' splits `item.meta.rows` in half, regenerates two child
174
+ // SQL statements via `item.meta.rowsToSql(rows)`, and re-enqueues both.
175
+ // - 'error-table' calls `onErrorTable({item, error, rowNum})` and counts
176
+ // as a drop.
177
+ // - `maxConsecutiveDrops` (default 5) drops within the same movement
178
+ // → auto-abort that movement.
179
+ // - 'fatal' → auto-abort that movement immediately.
180
+ //
181
+ // Backpressure: total serialized item bytes ≤ `maxMemoryMB * 1024 * 1024`
182
+ // (default 200MB high water). `addToQueue` returns a promise that awaits
183
+ // when over the ceiling, resumes at 75% low water.
184
+ //
185
+ // Encrypted connections: `connections: { name: encryptedString | plainConfig }`
186
+ // + `encryptionKey`. String values are decrypted via
187
+ // `@xeplr/utils/isomorphic/crypto` on first use and cached.
188
+ class SqlQueue {
189
+ constructor(options) {
190
+ options = options || {};
191
+ if (typeof options.executor !== 'function') {
192
+ throw new Error('SqlQueue: options.executor is required (async (item, resolvedConn) => void)');
193
+ }
194
+ this.executor = options.executor;
195
+ this.errorExecutor = options.errorExecutor || defaultErrorExecutor;
196
+ this.onErrorTable = options.onErrorTable || function() {};
197
+ this.onMovementAbort = options.onMovementAbort || function() {};
198
+ this.concurrency = options.concurrency || 4;
199
+ this.maxAttempts = options.maxAttempts || 3;
200
+ this.maxConsecutiveDrops= options.maxConsecutiveDrops|| 5;
201
+ this.maxMemoryBytes = (options.maxMemoryMB || 200) * 1024 * 1024;
202
+ this.lowMemoryBytes = Math.floor(this.maxMemoryBytes * 0.75);
203
+ this.retryDelaysMs = options.retryDelaysMs || [5000, 15000, 45000];
204
+ this.connections = options.connections || {};
205
+ this.encryptionKey = options.encryptionKey || null;
206
+
207
+ this._items = [];
208
+ this._itemId = 0;
209
+ this._byteTotal = 0;
210
+ this._inFlight = 0;
211
+ this._pendingRetries = 0; // items awaiting their retry setTimeout
212
+ this._stopped = false;
213
+ this._paused = false;
214
+ this._pressureWaiters = [];
215
+ this._idleWaiters = [];
216
+ this._resolved = {}; // decrypted connection cache
217
+ this._movements = new Map(); // movementId → state
218
+
219
+ this._workers = [];
220
+ for (var i = 0; i < this.concurrency; i++) {
221
+ this._workers.push(this._workerLoop());
222
+ }
223
+ }
224
+
225
+ // Enqueue an item. Awaits when the queue is over its memory ceiling.
226
+ // Item shape:
227
+ // { movementId, connection, sql?, meta: { rows?, rowsToSql?, errorTable?, ... }, attempt? }
228
+ // `sql` is generated from `meta.rowsToSql(meta.rows)` if not supplied.
229
+ async addToQueue(item) {
230
+ if (this._stopped) throw new Error('SqlQueue is stopped');
231
+ if (!item || !item.movementId) throw new Error('addToQueue: item.movementId is required');
232
+ if (!item.sql && item.meta && typeof item.meta.rowsToSql === 'function' && Array.isArray(item.meta.rows)) {
233
+ var builtA = item.meta.rowsToSql(item.meta.rows);
234
+ if (typeof builtA === 'string') { item.sql = builtA; }
235
+ else if (builtA && builtA.sql) { item.sql = builtA.sql; if (builtA.params != null) item.params = builtA.params; }
236
+ }
237
+ if (!item.sql) throw new Error('addToQueue: item.sql is required (or provide meta.rowsToSql + meta.rows)');
238
+
239
+ while (this._byteTotal >= this.maxMemoryBytes) {
240
+ await new Promise(function(res) { this._pressureWaiters.push(res); }.bind(this));
241
+ }
242
+
243
+ item.id = ++this._itemId;
244
+ item.attempt = item.attempt || 0;
245
+ item.bytes = approximateBytes(item);
246
+ this._byteTotal += item.bytes;
247
+ this._items.push(item);
248
+
249
+ var state = this._stateFor(item.movementId);
250
+ state.queued++;
251
+ }
252
+
253
+ // Abort a movement. Drops queued items for that movement and prevents
254
+ // in-flight retries. Emits onMovementAbort. In-flight SQLs are allowed
255
+ // to settle — caller runs uploader.rollback() to clean the DB.
256
+ abort(movementId, reason) {
257
+ var state = this._stateFor(movementId);
258
+ if (state.aborted) return;
259
+ state.aborted = true;
260
+ state.abortReason = reason || 'aborted';
261
+
262
+ var kept = [];
263
+ for (var i = 0; i < this._items.length; i++) {
264
+ var it = this._items[i];
265
+ if (it.movementId === movementId) {
266
+ this._byteTotal -= it.bytes;
267
+ state.queued--;
268
+ state.dropped += rowsOf(it);
269
+ state.statementsDropped++;
270
+ } else {
271
+ kept.push(it);
272
+ }
273
+ }
274
+ this._items = kept;
275
+ this._releaseBackpressure();
276
+
277
+ try { this.onMovementAbort({ movementId: movementId, reason: state.abortReason }); }
278
+ catch (e) { /* swallow — never let a hook block */ }
279
+ }
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
+
306
+ // Stats: per-movement if id passed, else global.
307
+ stats(movementId) {
308
+ if (movementId) {
309
+ var s = this._movements.get(movementId);
310
+ return s ? Object.assign({}, s) : null;
311
+ }
312
+ var total = { queued: this._items.length, inFlight: this._inFlight, bytes: this._byteTotal, movements: this._movements.size };
313
+ return total;
314
+ }
315
+
316
+ // Resolve when the queue drains AND no items are in flight AND no
317
+ // retry is pending. Useful after a spool ends and before checking
318
+ // final counts.
319
+ async drain() {
320
+ while (this._items.length > 0 || this._inFlight > 0 || this._pendingRetries > 0) {
321
+ await new Promise(function(res) { this._idleWaiters.push(res); }.bind(this));
322
+ }
323
+ }
324
+
325
+ pause() { this._paused = true; }
326
+ resume() { if (this._paused) { this._paused = false; this._kick(); } }
327
+ stop() { this._stopped = true; this._paused = true; this._releaseBackpressure(); }
328
+
329
+ // ─── internal ─────────────────────────────────────────────────────────
330
+ _stateFor(movementId) {
331
+ var s = this._movements.get(movementId);
332
+ if (!s) {
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
+ };
350
+ this._movements.set(movementId, s);
351
+ }
352
+ return s;
353
+ }
354
+
355
+ async _workerLoop() {
356
+ while (!this._stopped) {
357
+ if (this._paused || this._items.length === 0 || this._byteTotal === 0) {
358
+ await sleep(25);
359
+ this._maybeSignalIdle();
360
+ continue;
361
+ }
362
+ // Pick the first item whose movement isn't aborted.
363
+ var idx = -1;
364
+ for (var i = 0; i < this._items.length; i++) {
365
+ var m = this._items[i].movementId;
366
+ if (!this._stateFor(m).aborted) { idx = i; break; }
367
+ }
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
+ }
387
+
388
+ var item = this._items.splice(idx, 1)[0];
389
+ this._byteTotal -= item.bytes;
390
+ this._releaseBackpressure();
391
+
392
+ var state = this._stateFor(item.movementId);
393
+ state.queued--;
394
+ state.inFlight++;
395
+ this._inFlight++;
396
+
397
+ try { await this._processItem(item, state); }
398
+ catch (e) { /* processItem never throws — belt and braces */ }
399
+ finally { state.inFlight--; this._inFlight--; this._maybeSignalIdle(); }
400
+ }
401
+ }
402
+
403
+ async _processItem(item, state) {
404
+ if (state.aborted) return;
405
+
406
+ var conn;
407
+ try { conn = await this._resolveConnection(item.connection); }
408
+ catch (err) {
409
+ // Connection resolution failure = fatal for the movement.
410
+ state.dropped += rowsOf(item);
411
+ state.statementsDropped++;
412
+ this.abort(item.movementId, 'connection_resolve_failed: ' + err.message);
413
+ return;
414
+ }
415
+
416
+ item.attempt++;
417
+ try {
418
+ await this.executor(item, conn);
419
+ state.completed += rowsOf(item);
420
+ state.statements++;
421
+ state.consecutiveDrops = 0;
422
+ return;
423
+ } catch (err) {
424
+ var attemptedFinal = item.attempt >= this.maxAttempts;
425
+ var decision = await this.errorExecutor({ item: item, error: err, attempt: item.attempt, isFinal: attemptedFinal });
426
+ decision = decision || {};
427
+
428
+ if (decision.decision === 'retry' && !attemptedFinal && !state.aborted) {
429
+ // Delays indexed by "which retry": 1st retry uses [0], 2nd uses [1], etc.
430
+ var delayIdx = Math.min(item.attempt - 1, this.retryDelaysMs.length - 1);
431
+ var delay = decision.delayMs != null ? decision.delayMs : this.retryDelaysMs[delayIdx];
432
+ this._pendingRetries++;
433
+ setTimeout(function() {
434
+ this._pendingRetries--;
435
+ if (!state.aborted && !this._stopped) {
436
+ item.bytes = approximateBytes(item);
437
+ this._byteTotal += item.bytes;
438
+ this._items.push(item);
439
+ state.queued++;
440
+ }
441
+ this._maybeSignalIdle();
442
+ }.bind(this), delay);
443
+ return;
444
+ }
445
+
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
+ }
456
+ var mid = Math.floor(item.meta.rows.length / 2);
457
+ var left = cloneItemWithRows(item, item.meta.rows.slice(0, mid));
458
+ var right = cloneItemWithRows(item, item.meta.rows.slice(mid));
459
+ await this.addToQueue(left);
460
+ await this.addToQueue(right);
461
+ return;
462
+ }
463
+
464
+ if (decision.decision === 'fatal') {
465
+ this.abort(item.movementId, 'fatal: ' + (decision.reason || err.message));
466
+ return;
467
+ }
468
+
469
+ // 'error-table' | 'drop' | anything else past max attempts → drop
470
+ state.dropped += rowsOf(item);
471
+ state.statementsDropped++;
472
+ state.consecutiveDrops++;
473
+ try {
474
+ this.onErrorTable({
475
+ item: item,
476
+ error: err,
477
+ rowNum: item.meta && item.meta.rowNum,
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
486
+ });
487
+ } catch (_) { /* swallow */ }
488
+ if (state.consecutiveDrops >= this.maxConsecutiveDrops) {
489
+ this.abort(item.movementId, 'consecutive_drops_exceeded');
490
+ }
491
+ }
492
+ }
493
+
494
+ async _resolveConnection(name) {
495
+ if (this._resolved[name]) return this._resolved[name];
496
+ var raw = this.connections[name];
497
+ if (raw === undefined) throw new Error('SqlQueue: unknown connection "' + name + '"');
498
+ if (typeof raw === 'object') { this._resolved[name] = raw; return raw; }
499
+ if (typeof raw !== 'string') throw new Error('SqlQueue: connection "' + name + '" must be encrypted string or plain object');
500
+ if (!this.encryptionKey) throw new Error('SqlQueue: encryptionKey required to decrypt connection "' + name + '"');
501
+ var { decrypt } = require('../isomorphic/crypto');
502
+ var decrypted = await decrypt(raw, this.encryptionKey);
503
+ var config = JSON.parse(decrypted);
504
+ this._resolved[name] = config;
505
+ return config;
506
+ }
507
+
508
+ _releaseBackpressure() {
509
+ if (this._byteTotal <= this.lowMemoryBytes && this._pressureWaiters.length) {
510
+ var waiters = this._pressureWaiters;
511
+ this._pressureWaiters = [];
512
+ for (var i = 0; i < waiters.length; i++) waiters[i]();
513
+ }
514
+ }
515
+
516
+ _maybeSignalIdle() {
517
+ if (this._items.length === 0 && this._inFlight === 0 && this._pendingRetries === 0 && this._idleWaiters.length) {
518
+ var waiters = this._idleWaiters;
519
+ this._idleWaiters = [];
520
+ for (var i = 0; i < waiters.length; i++) waiters[i]();
521
+ }
522
+ }
523
+
524
+ _kick() { /* worker loops poll — nothing to do */ }
525
+ }
526
+
527
+ function defaultErrorExecutor(ctx) {
528
+ // Default: retry until final attempt, then route the row (or the whole
529
+ // batch if not bisectable) to the error table.
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
+ };
554
+ }
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
+ };
564
+ }
565
+
566
+ function cloneItemWithRows(item, rows) {
567
+ var clonedMeta = Object.assign({}, item.meta, { rows: rows, rowCount: rows.length });
568
+ var next = {
569
+ movementId: item.movementId,
570
+ connection: item.connection,
571
+ meta: clonedMeta,
572
+ attempt: 0
573
+ };
574
+ if (typeof clonedMeta.rowsToSql === 'function') {
575
+ var built = clonedMeta.rowsToSql(rows);
576
+ if (typeof built === 'string') { next.sql = built; next.params = item.params; }
577
+ else if (built && built.sql) { next.sql = built.sql; next.params = built.params; }
578
+ } else {
579
+ next.sql = item.sql; next.params = item.params;
580
+ }
581
+ return next;
582
+ }
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
+
592
+ function approximateBytes(item) {
593
+ try { return JSON.stringify(item).length; } catch (_) { return 128; }
594
+ }
595
+
596
+ function sleep(ms) { return new Promise(function(res) { setTimeout(res, ms); }); }
597
+
160
598
  module.exports = Queue;
599
+ module.exports.Queue = Queue;
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,9 +1,13 @@
1
1
  {
2
2
  "name": "@xeplr/utils",
3
- "version": "1.0.0",
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
- "files": ["index.js", "lib/", "isomorphic/"],
6
+ "files": [
7
+ "index.js",
8
+ "lib/",
9
+ "isomorphic/"
10
+ ],
7
11
  "exports": {
8
12
  ".": "./index.js",
9
13
  "./isomorphic": "./isomorphic/index.js",
@@ -15,26 +19,55 @@
15
19
  "./lib/fileUploader": "./lib/fileUploader.js",
16
20
  "./lib/helpers": "./lib/helpers.js",
17
21
  "./lib/logger": "./lib/logger.js",
22
+ "./lib/otp": "./lib/otp.js",
18
23
  "./lib/queue": "./lib/queue.js",
19
24
  "./lib/rateLimiter": "./lib/rateLimiter.js",
20
- "./lib/response": "./lib/response.js"
25
+ "./lib/response": "./lib/response.js",
26
+ "./lib/sms": "./lib/sms.js"
21
27
  },
22
- "keywords": ["email", "brevo", "ses", "smtp", "cache", "redis", "queue", "logger", "utilities"],
28
+ "keywords": [
29
+ "email",
30
+ "brevo",
31
+ "ses",
32
+ "smtp",
33
+ "cache",
34
+ "redis",
35
+ "queue",
36
+ "logger",
37
+ "utilities"
38
+ ],
23
39
  "author": "xeplr",
24
40
  "license": "MIT",
25
- "repository": { "type": "git", "url": "https://github.com/Xeplr/xeplr-utils" },
26
- "publishConfig": { "access": "public" },
27
- "dependencies": {
28
- "ioredis": "^5.6.1",
29
- "multer": "^1.4.5-lts.1",
30
- "nodemailer": "^8.0.2"
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/Xeplr/xeplr-utils"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
31
47
  },
48
+ "dependencies": {},
32
49
  "peerDependencies": {
33
50
  "@aws-sdk/client-ses": "^3.0.0",
34
- "@azure/communication-email": "^1.0.0"
51
+ "@azure/communication-email": "^1.0.0",
52
+ "ioredis": "^5.6.1",
53
+ "multer": "^1.4.5-lts.1",
54
+ "nodemailer": "^8.0.2"
35
55
  },
36
56
  "peerDependenciesMeta": {
37
- "@aws-sdk/client-ses": { "optional": true },
38
- "@azure/communication-email": { "optional": true }
57
+ "@aws-sdk/client-ses": {
58
+ "optional": true
59
+ },
60
+ "@azure/communication-email": {
61
+ "optional": true
62
+ },
63
+ "ioredis": {
64
+ "optional": true
65
+ },
66
+ "multer": {
67
+ "optional": true
68
+ },
69
+ "nodemailer": {
70
+ "optional": true
71
+ }
39
72
  }
40
73
  }