@xeplr/utils 1.0.0 → 1.0.1

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,4 +1,4 @@
1
- const { sendEmail, configureEmail } = require('./lib/email');
1
+ const { sendEmail, configureEmail, emailConfigFromEnv, 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');
@@ -7,11 +7,14 @@ const Queue = require('./lib/queue');
7
7
  const FileUploader = require('./lib/fileUploader');
8
8
  const RateLimiter = require('./lib/rateLimiter');
9
9
  const sms = require('./lib/sms');
10
+ const otp = require('./lib/otp');
10
11
 
11
12
  module.exports = {
12
13
  // Email
13
14
  sendEmail,
14
15
  configureEmail,
16
+ emailConfigFromEnv,
17
+ configureFromEnv,
15
18
 
16
19
  // Logger client
17
20
  configureLogger,
@@ -42,5 +45,8 @@ module.exports = {
42
45
  RateLimiter,
43
46
 
44
47
  // SMS
45
- sms
48
+ sms,
49
+
50
+ // OTP (uses sms above for delivery)
51
+ otp
46
52
  };
@@ -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
 
@@ -102,7 +117,7 @@ function buildAttachments(attachments) {
102
117
 
103
118
  async function sendViaSMTP(to, subject, html, cc, attachments) {
104
119
  var cfg = getConfig().smtp;
105
- var transporter = nodemailer.createTransport({
120
+ var transporter = loadNodemailer().createTransport({
106
121
  host: cfg.host,
107
122
  port: cfg.port,
108
123
  secure: false,
@@ -122,7 +137,7 @@ async function sendViaAWS(to, subject, html, cc, attachments) {
122
137
  // If there are attachments or cc, use SESv2 with raw email via nodemailer
123
138
  if ((attachments && attachments.length) || (cc && cc.length)) {
124
139
  var { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
125
- var transporter = nodemailer.createTransport({ streamTransport: true });
140
+ var transporter = loadNodemailer().createTransport({ streamTransport: true });
126
141
 
127
142
  var mailOptions = { from: cfg.from, to, subject, html };
128
143
  if (cc && cc.length) mailOptions.cc = cc;
@@ -300,4 +315,35 @@ async function replayDeadLetters() {
300
315
  return items.length;
301
316
  }
302
317
 
303
- module.exports = { sendEmail, configureEmail, getDeadLetterQueue, replayDeadLetters };
318
+ // Build an email config from env (EMAIL_PROVIDER + provider vars). Pure — returns
319
+ // the config or null if no provider is set. The single source for env→email config.
320
+ function emailConfigFromEnv() {
321
+ var provider = process.env.EMAIL_PROVIDER;
322
+ if (!provider) return null;
323
+
324
+ var config = { provider: provider };
325
+ if (provider === 'brevo') {
326
+ config.brevo = {
327
+ apiKey: process.env.BREVO_API_KEY,
328
+ fromEmail: process.env.BREVO_FROM_EMAIL,
329
+ fromName: process.env.BREVO_FROM_NAME
330
+ };
331
+ } else if (provider === 'smtp') {
332
+ config.smtp = {
333
+ host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
334
+ user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM
335
+ };
336
+ }
337
+ // aws / azure read their own vars here when those providers are used.
338
+ return config;
339
+ }
340
+
341
+ // Configure the email sender straight from env. Returns true if a provider was set.
342
+ function configureFromEnv() {
343
+ var config = emailConfigFromEnv();
344
+ if (!config) return false;
345
+ configureEmail(config);
346
+ return true;
347
+ }
348
+
349
+ module.exports = { sendEmail, configureEmail, emailConfigFromEnv, 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,326 @@ class Queue {
157
157
  }
158
158
  }
159
159
 
160
+ // ─────────────────────────────────────────────────────────────────────────
161
+ // SqlQueue — in-memory concurrent worker pool for SQL execution.
162
+ //
163
+ // Movement-scoped: every item carries a `movementId`. Rollback is by
164
+ // movementId. Aborting drops all queued items for one movement without
165
+ // affecting others.
166
+ //
167
+ // Retry / bisect / drop / abort behavior:
168
+ // - Up to `maxAttempts` per item (default 3); errorExecutor decides
169
+ // what to do on each failure ('retry' | 'bisect' | 'error-table' |
170
+ // 'drop' | 'fatal').
171
+ // - 'bisect' splits `item.meta.rows` in half, regenerates two child
172
+ // SQL statements via `item.meta.rowsToSql(rows)`, and re-enqueues both.
173
+ // - 'error-table' calls `onErrorTable({item, error, rowNum})` and counts
174
+ // as a drop.
175
+ // - `maxConsecutiveDrops` (default 5) drops within the same movement
176
+ // → auto-abort that movement.
177
+ // - 'fatal' → auto-abort that movement immediately.
178
+ //
179
+ // Backpressure: total serialized item bytes ≤ `maxMemoryMB * 1024 * 1024`
180
+ // (default 200MB high water). `addToQueue` returns a promise that awaits
181
+ // when over the ceiling, resumes at 75% low water.
182
+ //
183
+ // Encrypted connections: `connections: { name: encryptedString | plainConfig }`
184
+ // + `encryptionKey`. String values are decrypted via
185
+ // `@xeplr/utils/isomorphic/crypto` on first use and cached.
186
+ class SqlQueue {
187
+ constructor(options) {
188
+ options = options || {};
189
+ if (typeof options.executor !== 'function') {
190
+ throw new Error('SqlQueue: options.executor is required (async (item, resolvedConn) => void)');
191
+ }
192
+ this.executor = options.executor;
193
+ this.errorExecutor = options.errorExecutor || defaultErrorExecutor;
194
+ this.onErrorTable = options.onErrorTable || function() {};
195
+ this.onMovementAbort = options.onMovementAbort || function() {};
196
+ this.concurrency = options.concurrency || 4;
197
+ this.maxAttempts = options.maxAttempts || 3;
198
+ this.maxConsecutiveDrops= options.maxConsecutiveDrops|| 5;
199
+ this.maxMemoryBytes = (options.maxMemoryMB || 200) * 1024 * 1024;
200
+ this.lowMemoryBytes = Math.floor(this.maxMemoryBytes * 0.75);
201
+ this.retryDelaysMs = options.retryDelaysMs || [5000, 15000, 45000];
202
+ this.connections = options.connections || {};
203
+ this.encryptionKey = options.encryptionKey || null;
204
+
205
+ this._items = [];
206
+ this._itemId = 0;
207
+ this._byteTotal = 0;
208
+ this._inFlight = 0;
209
+ this._pendingRetries = 0; // items awaiting their retry setTimeout
210
+ this._stopped = false;
211
+ this._paused = false;
212
+ this._pressureWaiters = [];
213
+ this._idleWaiters = [];
214
+ this._resolved = {}; // decrypted connection cache
215
+ this._movements = new Map(); // movementId → state
216
+
217
+ this._workers = [];
218
+ for (var i = 0; i < this.concurrency; i++) {
219
+ this._workers.push(this._workerLoop());
220
+ }
221
+ }
222
+
223
+ // Enqueue an item. Awaits when the queue is over its memory ceiling.
224
+ // Item shape:
225
+ // { movementId, connection, sql?, meta: { rows?, rowsToSql?, errorTable?, ... }, attempt? }
226
+ // `sql` is generated from `meta.rowsToSql(meta.rows)` if not supplied.
227
+ async addToQueue(item) {
228
+ if (this._stopped) throw new Error('SqlQueue is stopped');
229
+ if (!item || !item.movementId) throw new Error('addToQueue: item.movementId is required');
230
+ if (!item.sql && item.meta && typeof item.meta.rowsToSql === 'function' && Array.isArray(item.meta.rows)) {
231
+ var builtA = item.meta.rowsToSql(item.meta.rows);
232
+ if (typeof builtA === 'string') { item.sql = builtA; }
233
+ else if (builtA && builtA.sql) { item.sql = builtA.sql; if (builtA.params != null) item.params = builtA.params; }
234
+ }
235
+ if (!item.sql) throw new Error('addToQueue: item.sql is required (or provide meta.rowsToSql + meta.rows)');
236
+
237
+ while (this._byteTotal >= this.maxMemoryBytes) {
238
+ await new Promise(function(res) { this._pressureWaiters.push(res); }.bind(this));
239
+ }
240
+
241
+ item.id = ++this._itemId;
242
+ item.attempt = item.attempt || 0;
243
+ item.bytes = approximateBytes(item);
244
+ this._byteTotal += item.bytes;
245
+ this._items.push(item);
246
+
247
+ var state = this._stateFor(item.movementId);
248
+ state.queued++;
249
+ }
250
+
251
+ // Abort a movement. Drops queued items for that movement and prevents
252
+ // in-flight retries. Emits onMovementAbort. In-flight SQLs are allowed
253
+ // to settle — caller runs uploader.rollback() to clean the DB.
254
+ abort(movementId, reason) {
255
+ var state = this._stateFor(movementId);
256
+ if (state.aborted) return;
257
+ state.aborted = true;
258
+ state.abortReason = reason || 'aborted';
259
+
260
+ var kept = [];
261
+ for (var i = 0; i < this._items.length; i++) {
262
+ var it = this._items[i];
263
+ if (it.movementId === movementId) {
264
+ this._byteTotal -= it.bytes;
265
+ state.queued--;
266
+ state.dropped++;
267
+ } else {
268
+ kept.push(it);
269
+ }
270
+ }
271
+ this._items = kept;
272
+ this._releaseBackpressure();
273
+
274
+ try { this.onMovementAbort({ movementId: movementId, reason: state.abortReason }); }
275
+ catch (e) { /* swallow — never let a hook block */ }
276
+ }
277
+
278
+ // Stats: per-movement if id passed, else global.
279
+ stats(movementId) {
280
+ if (movementId) {
281
+ var s = this._movements.get(movementId);
282
+ return s ? Object.assign({}, s) : null;
283
+ }
284
+ var total = { queued: this._items.length, inFlight: this._inFlight, bytes: this._byteTotal, movements: this._movements.size };
285
+ return total;
286
+ }
287
+
288
+ // Resolve when the queue drains AND no items are in flight AND no
289
+ // retry is pending. Useful after a spool ends and before checking
290
+ // final counts.
291
+ async drain() {
292
+ while (this._items.length > 0 || this._inFlight > 0 || this._pendingRetries > 0) {
293
+ await new Promise(function(res) { this._idleWaiters.push(res); }.bind(this));
294
+ }
295
+ }
296
+
297
+ pause() { this._paused = true; }
298
+ resume() { if (this._paused) { this._paused = false; this._kick(); } }
299
+ stop() { this._stopped = true; this._paused = true; this._releaseBackpressure(); }
300
+
301
+ // ─── internal ─────────────────────────────────────────────────────────
302
+ _stateFor(movementId) {
303
+ var s = this._movements.get(movementId);
304
+ if (!s) {
305
+ s = { movementId: movementId, queued: 0, inFlight: 0, completed: 0, dropped: 0, aborted: false, abortReason: null, consecutiveDrops: 0 };
306
+ this._movements.set(movementId, s);
307
+ }
308
+ return s;
309
+ }
310
+
311
+ async _workerLoop() {
312
+ while (!this._stopped) {
313
+ if (this._paused || this._items.length === 0 || this._byteTotal === 0) {
314
+ await sleep(25);
315
+ this._maybeSignalIdle();
316
+ continue;
317
+ }
318
+ // Pick the first item whose movement isn't aborted.
319
+ var idx = -1;
320
+ for (var i = 0; i < this._items.length; i++) {
321
+ var m = this._items[i].movementId;
322
+ if (!this._stateFor(m).aborted) { idx = i; break; }
323
+ }
324
+ if (idx < 0) { await sleep(25); continue; }
325
+
326
+ var item = this._items.splice(idx, 1)[0];
327
+ this._byteTotal -= item.bytes;
328
+ this._releaseBackpressure();
329
+
330
+ var state = this._stateFor(item.movementId);
331
+ state.queued--;
332
+ state.inFlight++;
333
+ this._inFlight++;
334
+
335
+ try { await this._processItem(item, state); }
336
+ catch (e) { /* processItem never throws — belt and braces */ }
337
+ finally { state.inFlight--; this._inFlight--; this._maybeSignalIdle(); }
338
+ }
339
+ }
340
+
341
+ async _processItem(item, state) {
342
+ if (state.aborted) return;
343
+
344
+ var conn;
345
+ try { conn = await this._resolveConnection(item.connection); }
346
+ catch (err) {
347
+ // Connection resolution failure = fatal for the movement.
348
+ state.dropped++;
349
+ this.abort(item.movementId, 'connection_resolve_failed: ' + err.message);
350
+ return;
351
+ }
352
+
353
+ item.attempt++;
354
+ try {
355
+ await this.executor(item, conn);
356
+ state.completed++;
357
+ state.consecutiveDrops = 0;
358
+ return;
359
+ } catch (err) {
360
+ var attemptedFinal = item.attempt >= this.maxAttempts;
361
+ var decision = await this.errorExecutor({ item: item, error: err, attempt: item.attempt, isFinal: attemptedFinal });
362
+ decision = decision || {};
363
+
364
+ if (decision.decision === 'retry' && !attemptedFinal && !state.aborted) {
365
+ // Delays indexed by "which retry": 1st retry uses [0], 2nd uses [1], etc.
366
+ var delayIdx = Math.min(item.attempt - 1, this.retryDelaysMs.length - 1);
367
+ var delay = decision.delayMs != null ? decision.delayMs : this.retryDelaysMs[delayIdx];
368
+ this._pendingRetries++;
369
+ setTimeout(function() {
370
+ this._pendingRetries--;
371
+ if (!state.aborted && !this._stopped) {
372
+ item.bytes = approximateBytes(item);
373
+ this._byteTotal += item.bytes;
374
+ this._items.push(item);
375
+ state.queued++;
376
+ }
377
+ this._maybeSignalIdle();
378
+ }.bind(this), delay);
379
+ return;
380
+ }
381
+
382
+ if (decision.decision === 'bisect' && item.meta && Array.isArray(item.meta.rows) && item.meta.rows.length > 1) {
383
+ var mid = Math.floor(item.meta.rows.length / 2);
384
+ var left = cloneItemWithRows(item, item.meta.rows.slice(0, mid));
385
+ var right = cloneItemWithRows(item, item.meta.rows.slice(mid));
386
+ await this.addToQueue(left);
387
+ await this.addToQueue(right);
388
+ return;
389
+ }
390
+
391
+ if (decision.decision === 'fatal') {
392
+ this.abort(item.movementId, 'fatal: ' + (decision.reason || err.message));
393
+ return;
394
+ }
395
+
396
+ // 'error-table' | 'drop' | anything else past max attempts → drop
397
+ state.dropped++;
398
+ state.consecutiveDrops++;
399
+ try {
400
+ this.onErrorTable({
401
+ item: item,
402
+ error: err,
403
+ rowNum: item.meta && item.meta.rowNum,
404
+ reason: decision.reason || err.message
405
+ });
406
+ } catch (_) { /* swallow */ }
407
+ if (state.consecutiveDrops >= this.maxConsecutiveDrops) {
408
+ this.abort(item.movementId, 'consecutive_drops_exceeded');
409
+ }
410
+ }
411
+ }
412
+
413
+ async _resolveConnection(name) {
414
+ if (this._resolved[name]) return this._resolved[name];
415
+ var raw = this.connections[name];
416
+ if (raw === undefined) throw new Error('SqlQueue: unknown connection "' + name + '"');
417
+ if (typeof raw === 'object') { this._resolved[name] = raw; return raw; }
418
+ if (typeof raw !== 'string') throw new Error('SqlQueue: connection "' + name + '" must be encrypted string or plain object');
419
+ if (!this.encryptionKey) throw new Error('SqlQueue: encryptionKey required to decrypt connection "' + name + '"');
420
+ var { decrypt } = require('../isomorphic/crypto');
421
+ var decrypted = await decrypt(raw, this.encryptionKey);
422
+ var config = JSON.parse(decrypted);
423
+ this._resolved[name] = config;
424
+ return config;
425
+ }
426
+
427
+ _releaseBackpressure() {
428
+ if (this._byteTotal <= this.lowMemoryBytes && this._pressureWaiters.length) {
429
+ var waiters = this._pressureWaiters;
430
+ this._pressureWaiters = [];
431
+ for (var i = 0; i < waiters.length; i++) waiters[i]();
432
+ }
433
+ }
434
+
435
+ _maybeSignalIdle() {
436
+ if (this._items.length === 0 && this._inFlight === 0 && this._pendingRetries === 0 && this._idleWaiters.length) {
437
+ var waiters = this._idleWaiters;
438
+ this._idleWaiters = [];
439
+ for (var i = 0; i < waiters.length; i++) waiters[i]();
440
+ }
441
+ }
442
+
443
+ _kick() { /* worker loops poll — nothing to do */ }
444
+ }
445
+
446
+ function defaultErrorExecutor(ctx) {
447
+ // Default: retry until final attempt, then route the row (or the whole
448
+ // batch if not bisectable) to the error table.
449
+ if (!ctx.isFinal) return { decision: 'retry' };
450
+ if (ctx.item.meta && Array.isArray(ctx.item.meta.rows) && ctx.item.meta.rows.length > 1) {
451
+ return { decision: 'bisect' };
452
+ }
453
+ return { decision: 'error-table', reason: ctx.error && ctx.error.message };
454
+ }
455
+
456
+ function cloneItemWithRows(item, rows) {
457
+ var clonedMeta = Object.assign({}, item.meta, { rows: rows, rowCount: rows.length });
458
+ var next = {
459
+ movementId: item.movementId,
460
+ connection: item.connection,
461
+ meta: clonedMeta,
462
+ attempt: 0
463
+ };
464
+ if (typeof clonedMeta.rowsToSql === 'function') {
465
+ var built = clonedMeta.rowsToSql(rows);
466
+ if (typeof built === 'string') { next.sql = built; next.params = item.params; }
467
+ else if (built && built.sql) { next.sql = built.sql; next.params = built.params; }
468
+ } else {
469
+ next.sql = item.sql; next.params = item.params;
470
+ }
471
+ return next;
472
+ }
473
+
474
+ function approximateBytes(item) {
475
+ try { return JSON.stringify(item).length; } catch (_) { return 128; }
476
+ }
477
+
478
+ function sleep(ms) { return new Promise(function(res) { setTimeout(res, ms); }); }
479
+
160
480
  module.exports = Queue;
481
+ module.exports.Queue = Queue;
482
+ module.exports.SqlQueue = SqlQueue;
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@xeplr/utils",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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
  }