@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 +15 -2
- package/isomorphic/crypto.js +60 -1
- package/lib/cache.js +22 -2
- package/lib/email.js +183 -10
- package/lib/fileUploader.js +17 -1
- package/lib/otp.js +111 -0
- package/lib/queue.js +441 -0
- package/lib/sql-error.js +208 -0
- package/package.json +46 -13
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
|
};
|
package/isomorphic/crypto.js
CHANGED
|
@@ -120,4 +120,63 @@ async function decrypt(encrypted, key) {
|
|
|
120
120
|
return new TextDecoder().decode(decrypted);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
108
|
-
secure:
|
|
109
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 };
|
package/lib/fileUploader.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
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;
|
package/lib/sql-error.js
ADDED
|
@@ -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.
|
|
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": [
|
|
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": [
|
|
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": {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
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": {
|
|
38
|
-
|
|
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
|
}
|