@prosopo/database 3.15.19 → 3.15.20

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.
@@ -1,476 +1,434 @@
1
- "use strict";
2
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const BIGINT_TAG = "__bigint__:";
4
- const bigIntReplacer = (_key, value) => "bigint" === typeof value ? `${BIGINT_TAG}${value.toString()}` : value;
5
- const bigIntReviver = (_key, value) => "string" === typeof value && value.startsWith(BIGINT_TAG) ? BigInt(value.slice(BIGINT_TAG.length)) : value;
6
- const SESSION_KEY_PATTERNS = [
7
- "cache:session:*",
8
- "writeq:session:*",
9
- "writeq:session:pending"
1
+ //#region src/redisCache.ts
2
+ var BIGINT_TAG = "__bigint__:";
3
+ /** JSON replacer that tags BigInt values so they can be restored by bigIntReviver. */
4
+ var bigIntReplacer = (_key, value) => "bigint" === typeof value ? `${BIGINT_TAG}${value.toString()}` : value;
5
+ /** JSON reviver that restores tagged BigInt values produced by bigIntReplacer. */
6
+ var bigIntReviver = (_key, value) => "string" === typeof value && value.startsWith(BIGINT_TAG) ? BigInt(value.slice(11)) : value;
7
+ var SESSION_KEY_PATTERNS = [
8
+ "cache:session:*",
9
+ "writeq:session:*",
10
+ "writeq:session:pending"
10
11
  ];
11
- class RedisWriteQueue {
12
- constructor(connection, logger) {
13
- this.flushTimer = null;
14
- this.flushCallback = null;
15
- this.connection = connection;
16
- this.logger = logger;
17
- }
18
- async getClient() {
19
- if (!this.connection.isReady()) {
20
- return null;
21
- }
22
- try {
23
- return await this.connection.getClient();
24
- } catch {
25
- return null;
26
- }
27
- }
28
- // ── Session read cache ──────────────────────────────────────────────
29
- /**
30
- * Cache a session record in Redis for fast lookups, reducing MongoDB reads.
31
- */
32
- async cacheSession(sessionId, sessionData, ttlSeconds = 86400) {
33
- const client = await this.getClient();
34
- if (!client) {
35
- return false;
36
- }
37
- try {
38
- const key = `cache:session:${sessionId}`;
39
- await client.set(key, JSON.stringify(sessionData, bigIntReplacer), {
40
- EX: ttlSeconds
41
- });
42
- return true;
43
- } catch (error) {
44
- this.logger.warn(() => ({
45
- msg: "Failed to cache session in Redis",
46
- err: error,
47
- sessionId
48
- }));
49
- return false;
50
- }
51
- }
52
- /**
53
- * Retrieve a cached session record from Redis.
54
- */
55
- async getCachedSession(sessionId) {
56
- const client = await this.getClient();
57
- if (!client) {
58
- return null;
59
- }
60
- try {
61
- const key = `cache:session:${sessionId}`;
62
- const data = await client.get(key);
63
- if (!data) {
64
- return null;
65
- }
66
- return JSON.parse(data, bigIntReviver);
67
- } catch (error) {
68
- this.logger.warn(() => ({
69
- msg: "Failed to get cached session from Redis",
70
- err: error,
71
- sessionId
72
- }));
73
- return null;
74
- }
75
- }
76
- /**
77
- * Patch the cached session in place — read the existing entry, shallow-
78
- * merge the provided updates, and write it back. No-op on cache miss so
79
- * the next read repopulates from Mongo. Mirrors the field-level Mongo
80
- * patch done by `db.updateSessionRecord`; callers invoke both to keep
81
- * Redis consistent with the DB after every in-request write.
82
- */
83
- async patchCachedSession(sessionId, updates, ttlSeconds = 86400) {
84
- const existing = await this.getCachedSession(sessionId);
85
- if (!existing) return false;
86
- return this.cacheSession(
87
- sessionId,
88
- { ...existing, ...updates, lastUpdatedTimestamp: /* @__PURE__ */ new Date() },
89
- ttlSeconds
90
- );
91
- }
92
- /**
93
- * First-hop-wins SIMD attach on the cache — mirrors the atomic Mongo
94
- * `$ifNull` pipeline update in `db.recordSessionSimdReadingsIfAbsent`.
95
- * Reads the cache; if it doesn't already carry `simdReadings`, merges
96
- * the new readings + stage. No-op on cache miss or when readings are
97
- * already present.
98
- */
99
- async patchCachedSimdReadingsIfAbsent(sessionId, readings, stage, ttlSeconds = 86400) {
100
- const existing = await this.getCachedSession(sessionId);
101
- if (!existing) return false;
102
- if (existing.simdReadings) return false;
103
- return this.cacheSession(
104
- sessionId,
105
- {
106
- ...existing,
107
- simdReadings: readings,
108
- simdReadingsStage: stage,
109
- lastUpdatedTimestamp: /* @__PURE__ */ new Date()
110
- },
111
- ttlSeconds
112
- );
113
- }
114
- /**
115
- * Invalidate a cached session when it's updated.
116
- */
117
- async invalidateCachedSession(sessionId) {
118
- const client = await this.getClient();
119
- if (!client) {
120
- return;
121
- }
122
- try {
123
- await client.del(`cache:session:${sessionId}`);
124
- } catch (error) {
125
- this.logger.warn(() => ({
126
- msg: "Failed to invalidate cached session in Redis",
127
- err: error,
128
- sessionId
129
- }));
130
- }
131
- }
132
- /**
133
- * Invalidate the hash → sessionId mapping used by /frictionless for
134
- * dedup. Must be called alongside `invalidateCachedSession` when a
135
- * session is consumed, otherwise the hash mapping keeps resolving to
136
- * a dead sessionId for the remainder of its 1-hour TTL.
137
- */
138
- async invalidateCachedSessionByHash(userSitekeyIpHash) {
139
- const client = await this.getClient();
140
- if (!client) {
141
- return;
142
- }
143
- try {
144
- await client.del(`cache:session:hash:${userSitekeyIpHash}`);
145
- } catch (error) {
146
- this.logger.warn(() => ({
147
- msg: "Failed to invalidate cached session hash in Redis",
148
- err: error,
149
- userSitekeyIpHash
150
- }));
151
- }
152
- }
153
- // ── Frictionless session deduplication cache ────────────────────────
154
- /**
155
- * Cache a session lookup by userSitekeyIpHash for frictionless deduplication.
156
- * Extends the effective TTL of the cached session, reducing duplicate
157
- * session creation under concurrent requests for the same user+site+IP.
158
- */
159
- async cacheSessionByHash(userSitekeyIpHash, sessionId, ttlSeconds = 3600) {
160
- const client = await this.getClient();
161
- if (!client) {
162
- return false;
163
- }
164
- try {
165
- const key = `cache:session:hash:${userSitekeyIpHash}`;
166
- await client.set(key, sessionId, { EX: ttlSeconds });
167
- return true;
168
- } catch (error) {
169
- this.logger.warn(() => ({
170
- msg: "Failed to cache session hash in Redis",
171
- err: error,
172
- userSitekeyIpHash
173
- }));
174
- return false;
175
- }
176
- }
177
- /**
178
- * Get cached sessionId by userSitekeyIpHash for frictionless deduplication.
179
- */
180
- async getCachedSessionByHash(userSitekeyIpHash) {
181
- const client = await this.getClient();
182
- if (!client) {
183
- return null;
184
- }
185
- try {
186
- const key = `cache:session:hash:${userSitekeyIpHash}`;
187
- return await client.get(key);
188
- } catch (error) {
189
- this.logger.warn(() => ({
190
- msg: "Failed to get cached session hash from Redis",
191
- err: error,
192
- userSitekeyIpHash
193
- }));
194
- return null;
195
- }
196
- }
197
- /**
198
- * Cache an origin → escalation sessionId mapping for the post-PoW
199
- * escalation flow.
200
- *
201
- * Background: when the routing machine returns image/puzzle from the
202
- * postPow phase, `buildEscalation` (`api/captcha/submitPoWCaptchaSolution.ts`)
203
- * mints a new session with a fresh `sessionId` and returns it on the
204
- * `escalation` field. A widget that handles the escalation cleanly
205
- * mounts the next captcha with the new sessionId. But there are
206
- * several real-world paths where the widget keeps using the original
207
- * sessionId for the follow-up `/captcha/{type}` request — bundled SDK
208
- * versions that predate the escalation handler, dapps that hand-roll
209
- * the wrapper, network-glitch retries, or browser-tab races. The
210
- * original session has been consumed and soft-deleted by the
211
- * preceding `/captcha/pow`, so the follow-up gets
212
- * `CAPTCHA.NO_SESSION_FOUND` and the user sees an inline error → FAQ.
213
- *
214
- * This mapping lets `isValidRequest` resolve the original sessionId
215
- * forward to the live escalation session and proceed normally.
216
- *
217
- * TTL is short on purpose: escalation handoffs complete in seconds
218
- * (the user solves the follow-up immediately) and a stale mapping
219
- * pointing at a long-gone session would just produce a different
220
- * shape of NO_SESSION_FOUND.
221
- */
222
- async cacheSessionEscalation(originSessionId, escalationSessionId, ttlSeconds = 600) {
223
- const client = await this.getClient();
224
- if (!client) {
225
- return false;
226
- }
227
- try {
228
- const key = `cache:session:escalation:${originSessionId}`;
229
- await client.set(key, escalationSessionId, { EX: ttlSeconds });
230
- return true;
231
- } catch (error) {
232
- this.logger.warn(() => ({
233
- msg: "Failed to cache session escalation mapping in Redis",
234
- err: error,
235
- originSessionId,
236
- escalationSessionId
237
- }));
238
- return false;
239
- }
240
- }
241
- /**
242
- * Resolve an original sessionId to its escalation sessionId, if one was
243
- * recorded. Returns null when there is no mapping (either no escalation
244
- * happened, or the TTL has elapsed).
245
- */
246
- async getCachedSessionEscalation(originSessionId) {
247
- const client = await this.getClient();
248
- if (!client) {
249
- return null;
250
- }
251
- try {
252
- const key = `cache:session:escalation:${originSessionId}`;
253
- return await client.get(key);
254
- } catch (error) {
255
- this.logger.warn(() => ({
256
- msg: "Failed to get cached session escalation mapping from Redis",
257
- err: error,
258
- originSessionId
259
- }));
260
- return null;
261
- }
262
- }
263
- /**
264
- * Invalidate an escalation mapping. Called when the escalation session
265
- * itself is consumed so subsequent retries on the origin sessionId fall
266
- * through to the standard NO_SESSION_FOUND path rather than chasing a
267
- * second-hand stale pointer.
268
- */
269
- async invalidateCachedSessionEscalation(originSessionId) {
270
- const client = await this.getClient();
271
- if (!client) {
272
- return;
273
- }
274
- try {
275
- const key = `cache:session:escalation:${originSessionId}`;
276
- await client.del(key);
277
- } catch (error) {
278
- this.logger.warn(() => ({
279
- msg: "Failed to invalidate cached session escalation mapping",
280
- err: error,
281
- originSessionId
282
- }));
283
- }
284
- }
285
- // ── Session write queue ─────────────────────────────────────────────
286
- /**
287
- * Queue a session record for batched insertion into MongoDB.
288
- * The session data is also cached in Redis for immediate reads.
289
- */
290
- async queueSessionRecord(sessionId, record, ttlSeconds = 86400) {
291
- const client = await this.getClient();
292
- if (!client) {
293
- return false;
294
- }
295
- try {
296
- const key = `writeq:session:${sessionId}`;
297
- const serialized = JSON.stringify(record, bigIntReplacer);
298
- await client.set(key, serialized, { EX: ttlSeconds });
299
- await client.sAdd("writeq:session:pending", sessionId);
300
- await client.set(`cache:session:${sessionId}`, serialized, {
301
- EX: ttlSeconds
302
- });
303
- this.triggerEarlyFlushIfNeeded();
304
- return true;
305
- } catch (error) {
306
- this.logger.warn(() => ({
307
- msg: "Failed to queue session record in Redis",
308
- err: error,
309
- sessionId
310
- }));
311
- return false;
312
- }
313
- }
314
- /**
315
- * Get all pending session records ready for batch flush.
316
- */
317
- async drainSessionRecords(limit = 500) {
318
- const client = await this.getClient();
319
- if (!client) {
320
- return [];
321
- }
322
- try {
323
- const sessionIds = await client.sMembers("writeq:session:pending");
324
- const batch = sessionIds.slice(0, limit);
325
- const results = [];
326
- for (const sessionId of batch) {
327
- const key = `writeq:session:${sessionId}`;
328
- const data = await client.get(key);
329
- if (data) {
330
- results.push({
331
- sessionId,
332
- record: JSON.parse(data, bigIntReviver)
333
- });
334
- }
335
- await client.sRem("writeq:session:pending", sessionId);
336
- await client.del(key);
337
- }
338
- return results;
339
- } catch (error) {
340
- this.logger.warn(() => ({
341
- msg: "Failed to drain session records from Redis",
342
- err: error
343
- }));
344
- return [];
345
- }
346
- }
347
- // ── Periodic flush ──────────────────────────────────────────────────
348
- /**
349
- * Start periodic background flush of queued records.
350
- * The callback receives this queue instance and should drain + bulk-write records.
351
- *
352
- * When the queue depth exceeds `earlyFlushThreshold`, an early flush is
353
- * triggered on the next `queueSessionRecord` call without waiting for
354
- * the regular interval.
355
- */
356
- startPeriodicFlush(callback, intervalMs = 1e4, earlyFlushThreshold = 50) {
357
- this.stopPeriodicFlush();
358
- this.flushCallback = callback;
359
- this.earlyFlushThreshold = earlyFlushThreshold;
360
- this.flushTimer = setInterval(() => {
361
- this.flushCallback?.(this).catch((error) => {
362
- this.logger.error(() => ({
363
- msg: "Periodic flush failed",
364
- err: error
365
- }));
366
- });
367
- }, intervalMs);
368
- }
369
- /**
370
- * Trigger an early flush if the pending queue exceeds the threshold.
371
- * Called automatically after queueing a record. This avoids large
372
- * batch build-ups between regular interval flushes.
373
- */
374
- triggerEarlyFlushIfNeeded() {
375
- if (!this.flushCallback || !this.earlyFlushThreshold) return;
376
- this.getPendingCount().then((count) => {
377
- if (count >= (this.earlyFlushThreshold ?? 50)) {
378
- return this.flushCallback?.(this);
379
- }
380
- }).catch((error) => {
381
- this.logger.error(() => ({
382
- msg: "Early flush failed",
383
- err: error
384
- }));
385
- });
386
- }
387
- /** Get the number of pending session records awaiting flush. */
388
- async getPendingCount() {
389
- const client = await this.getClient();
390
- if (!client) return 0;
391
- try {
392
- return await client.sCard("writeq:session:pending");
393
- } catch {
394
- return 0;
395
- }
396
- }
397
- /**
398
- * Stop the periodic flush timer.
399
- */
400
- stopPeriodicFlush() {
401
- if (this.flushTimer) {
402
- clearInterval(this.flushTimer);
403
- this.flushTimer = null;
404
- }
405
- this.flushCallback = null;
406
- this.earlyFlushThreshold = void 0;
407
- }
408
- /**
409
- * Drop every session-related key from Redis. Intended for provider
410
- * startup so a fresh process can never inherit stale read-cache,
411
- * dedup-by-hash, or pending-write-queue entries written by an
412
- * earlier (possibly crashed) run.
413
- *
414
- * Uses SCAN — KEYS would block the Redis server on large keyspaces.
415
- *
416
- * Bypasses the `isReady()` fast-path that other methods use because
417
- * this runs during startup, before the Redis client's async
418
- * connect() handshake has had time to flip the flag. We await the
419
- * connection promise directly with a bounded timeout so we don't
420
- * hang the boot sequence when Redis is unreachable.
421
- */
422
- async clearAllSessionRecords(timeoutMs = 5e3) {
423
- this.logger.info(() => ({
424
- msg: "Clearing Redis session records at startup"
425
- }));
426
- let client;
427
- try {
428
- client = await Promise.race([
429
- this.connection.getClient(),
430
- new Promise(
431
- (_, reject) => setTimeout(
432
- () => reject(new Error("Redis connection timeout")),
433
- timeoutMs
434
- )
435
- )
436
- ]);
437
- } catch (error) {
438
- this.logger.warn(() => ({
439
- msg: "Skipped Redis session cleanup — Redis not reachable",
440
- err: error
441
- }));
442
- return;
443
- }
444
- try {
445
- let totalDeleted = 0;
446
- for (const pattern of SESSION_KEY_PATTERNS) {
447
- for await (const key of client.scanIterator({
448
- MATCH: pattern,
449
- COUNT: 500
450
- })) {
451
- const keys = Array.isArray(key) ? key : [key];
452
- if (keys.length > 0) {
453
- await client.del(keys);
454
- totalDeleted += keys.length;
455
- }
456
- }
457
- }
458
- this.logger.info(() => ({
459
- msg: "Cleared Redis session records at startup",
460
- data: { totalDeleted }
461
- }));
462
- } catch (error) {
463
- this.logger.warn(() => ({
464
- msg: "Failed to clear Redis session records at startup",
465
- err: error
466
- }));
467
- }
468
- }
469
- /**
470
- * Check if the underlying Redis connection is ready.
471
- */
472
- isReady() {
473
- return this.connection.isReady();
474
- }
475
- }
12
+ /**
13
+ * Redis-backed write queue and read cache for reducing MongoDB load.
14
+ *
15
+ * Provides:
16
+ * - Session record caching (reduces MongoDB reads)
17
+ * - Session-by-hash caching for frictionless deduplication
18
+ * - Session write queuing with periodic flush to MongoDB
19
+ *
20
+ * Uses an existing RedisConnection from @prosopo/redis-client rather than
21
+ * creating its own connection.
22
+ */
23
+ var RedisWriteQueue = class {
24
+ constructor(connection, logger) {
25
+ this.flushTimer = null;
26
+ this.flushCallback = null;
27
+ this.connection = connection;
28
+ this.logger = logger;
29
+ }
30
+ async getClient() {
31
+ if (!this.connection.isReady()) return null;
32
+ try {
33
+ return await this.connection.getClient();
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Cache a session record in Redis for fast lookups, reducing MongoDB reads.
40
+ */
41
+ async cacheSession(sessionId, sessionData, ttlSeconds = 86400) {
42
+ const client = await this.getClient();
43
+ if (!client) return false;
44
+ try {
45
+ const key = `cache:session:${sessionId}`;
46
+ await client.set(key, JSON.stringify(sessionData, bigIntReplacer), { EX: ttlSeconds });
47
+ return true;
48
+ } catch (error) {
49
+ this.logger.warn(() => ({
50
+ msg: "Failed to cache session in Redis",
51
+ err: error,
52
+ sessionId
53
+ }));
54
+ return false;
55
+ }
56
+ }
57
+ /**
58
+ * Retrieve a cached session record from Redis.
59
+ */
60
+ async getCachedSession(sessionId) {
61
+ const client = await this.getClient();
62
+ if (!client) return null;
63
+ try {
64
+ const key = `cache:session:${sessionId}`;
65
+ const data = await client.get(key);
66
+ if (!data) return null;
67
+ return JSON.parse(data, bigIntReviver);
68
+ } catch (error) {
69
+ this.logger.warn(() => ({
70
+ msg: "Failed to get cached session from Redis",
71
+ err: error,
72
+ sessionId
73
+ }));
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Patch the cached session in place — read the existing entry, shallow-
79
+ * merge the provided updates, and write it back. No-op on cache miss so
80
+ * the next read repopulates from Mongo. Mirrors the field-level Mongo
81
+ * patch done by `db.updateSessionRecord`; callers invoke both to keep
82
+ * Redis consistent with the DB after every in-request write.
83
+ */
84
+ async patchCachedSession(sessionId, updates, ttlSeconds = 86400) {
85
+ const existing = await this.getCachedSession(sessionId);
86
+ if (!existing) return false;
87
+ return this.cacheSession(sessionId, {
88
+ ...existing,
89
+ ...updates,
90
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date()
91
+ }, ttlSeconds);
92
+ }
93
+ /**
94
+ * First-hop-wins SIMD attach on the cache — mirrors the atomic Mongo
95
+ * `$ifNull` pipeline update in `db.recordSessionSimdReadingsIfAbsent`.
96
+ * Reads the cache; if it doesn't already carry `simdReadings`, merges
97
+ * the new readings + stage. No-op on cache miss or when readings are
98
+ * already present.
99
+ */
100
+ async patchCachedSimdReadingsIfAbsent(sessionId, readings, stage, ttlSeconds = 86400) {
101
+ const existing = await this.getCachedSession(sessionId);
102
+ if (!existing) return false;
103
+ if (existing.simdReadings) return false;
104
+ return this.cacheSession(sessionId, {
105
+ ...existing,
106
+ simdReadings: readings,
107
+ simdReadingsStage: stage,
108
+ lastUpdatedTimestamp: /* @__PURE__ */ new Date()
109
+ }, ttlSeconds);
110
+ }
111
+ /**
112
+ * Invalidate a cached session when it's updated.
113
+ */
114
+ async invalidateCachedSession(sessionId) {
115
+ const client = await this.getClient();
116
+ if (!client) return;
117
+ try {
118
+ await client.del(`cache:session:${sessionId}`);
119
+ } catch (error) {
120
+ this.logger.warn(() => ({
121
+ msg: "Failed to invalidate cached session in Redis",
122
+ err: error,
123
+ sessionId
124
+ }));
125
+ }
126
+ }
127
+ /**
128
+ * Invalidate the hash → sessionId mapping used by /frictionless for
129
+ * dedup. Must be called alongside `invalidateCachedSession` when a
130
+ * session is consumed, otherwise the hash mapping keeps resolving to
131
+ * a dead sessionId for the remainder of its 1-hour TTL.
132
+ */
133
+ async invalidateCachedSessionByHash(userSitekeyIpHash) {
134
+ const client = await this.getClient();
135
+ if (!client) return;
136
+ try {
137
+ await client.del(`cache:session:hash:${userSitekeyIpHash}`);
138
+ } catch (error) {
139
+ this.logger.warn(() => ({
140
+ msg: "Failed to invalidate cached session hash in Redis",
141
+ err: error,
142
+ userSitekeyIpHash
143
+ }));
144
+ }
145
+ }
146
+ /**
147
+ * Cache a session lookup by userSitekeyIpHash for frictionless deduplication.
148
+ * Extends the effective TTL of the cached session, reducing duplicate
149
+ * session creation under concurrent requests for the same user+site+IP.
150
+ */
151
+ async cacheSessionByHash(userSitekeyIpHash, sessionId, ttlSeconds = 3600) {
152
+ const client = await this.getClient();
153
+ if (!client) return false;
154
+ try {
155
+ const key = `cache:session:hash:${userSitekeyIpHash}`;
156
+ await client.set(key, sessionId, { EX: ttlSeconds });
157
+ return true;
158
+ } catch (error) {
159
+ this.logger.warn(() => ({
160
+ msg: "Failed to cache session hash in Redis",
161
+ err: error,
162
+ userSitekeyIpHash
163
+ }));
164
+ return false;
165
+ }
166
+ }
167
+ /**
168
+ * Get cached sessionId by userSitekeyIpHash for frictionless deduplication.
169
+ */
170
+ async getCachedSessionByHash(userSitekeyIpHash) {
171
+ const client = await this.getClient();
172
+ if (!client) return null;
173
+ try {
174
+ const key = `cache:session:hash:${userSitekeyIpHash}`;
175
+ return await client.get(key);
176
+ } catch (error) {
177
+ this.logger.warn(() => ({
178
+ msg: "Failed to get cached session hash from Redis",
179
+ err: error,
180
+ userSitekeyIpHash
181
+ }));
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Cache an origin → escalation sessionId mapping for the post-PoW
187
+ * escalation flow.
188
+ *
189
+ * Background: when the routing machine returns image/puzzle from the
190
+ * postPow phase, `buildEscalation` (`api/captcha/submitPoWCaptchaSolution.ts`)
191
+ * mints a new session with a fresh `sessionId` and returns it on the
192
+ * `escalation` field. A widget that handles the escalation cleanly
193
+ * mounts the next captcha with the new sessionId. But there are
194
+ * several real-world paths where the widget keeps using the original
195
+ * sessionId for the follow-up `/captcha/{type}` request — bundled SDK
196
+ * versions that predate the escalation handler, dapps that hand-roll
197
+ * the wrapper, network-glitch retries, or browser-tab races. The
198
+ * original session has been consumed and soft-deleted by the
199
+ * preceding `/captcha/pow`, so the follow-up gets
200
+ * `CAPTCHA.NO_SESSION_FOUND` and the user sees an inline error → FAQ.
201
+ *
202
+ * This mapping lets `isValidRequest` resolve the original sessionId
203
+ * forward to the live escalation session and proceed normally.
204
+ *
205
+ * TTL is short on purpose: escalation handoffs complete in seconds
206
+ * (the user solves the follow-up immediately) and a stale mapping
207
+ * pointing at a long-gone session would just produce a different
208
+ * shape of NO_SESSION_FOUND.
209
+ */
210
+ async cacheSessionEscalation(originSessionId, escalationSessionId, ttlSeconds = 600) {
211
+ const client = await this.getClient();
212
+ if (!client) return false;
213
+ try {
214
+ const key = `cache:session:escalation:${originSessionId}`;
215
+ await client.set(key, escalationSessionId, { EX: ttlSeconds });
216
+ return true;
217
+ } catch (error) {
218
+ this.logger.warn(() => ({
219
+ msg: "Failed to cache session escalation mapping in Redis",
220
+ err: error,
221
+ originSessionId,
222
+ escalationSessionId
223
+ }));
224
+ return false;
225
+ }
226
+ }
227
+ /**
228
+ * Resolve an original sessionId to its escalation sessionId, if one was
229
+ * recorded. Returns null when there is no mapping (either no escalation
230
+ * happened, or the TTL has elapsed).
231
+ */
232
+ async getCachedSessionEscalation(originSessionId) {
233
+ const client = await this.getClient();
234
+ if (!client) return null;
235
+ try {
236
+ const key = `cache:session:escalation:${originSessionId}`;
237
+ return await client.get(key);
238
+ } catch (error) {
239
+ this.logger.warn(() => ({
240
+ msg: "Failed to get cached session escalation mapping from Redis",
241
+ err: error,
242
+ originSessionId
243
+ }));
244
+ return null;
245
+ }
246
+ }
247
+ /**
248
+ * Invalidate an escalation mapping. Called when the escalation session
249
+ * itself is consumed so subsequent retries on the origin sessionId fall
250
+ * through to the standard NO_SESSION_FOUND path rather than chasing a
251
+ * second-hand stale pointer.
252
+ */
253
+ async invalidateCachedSessionEscalation(originSessionId) {
254
+ const client = await this.getClient();
255
+ if (!client) return;
256
+ try {
257
+ const key = `cache:session:escalation:${originSessionId}`;
258
+ await client.del(key);
259
+ } catch (error) {
260
+ this.logger.warn(() => ({
261
+ msg: "Failed to invalidate cached session escalation mapping",
262
+ err: error,
263
+ originSessionId
264
+ }));
265
+ }
266
+ }
267
+ /**
268
+ * Queue a session record for batched insertion into MongoDB.
269
+ * The session data is also cached in Redis for immediate reads.
270
+ */
271
+ async queueSessionRecord(sessionId, record, ttlSeconds = 86400) {
272
+ const client = await this.getClient();
273
+ if (!client) return false;
274
+ try {
275
+ const key = `writeq:session:${sessionId}`;
276
+ const serialized = JSON.stringify(record, bigIntReplacer);
277
+ await client.set(key, serialized, { EX: ttlSeconds });
278
+ await client.sAdd("writeq:session:pending", sessionId);
279
+ await client.set(`cache:session:${sessionId}`, serialized, { EX: ttlSeconds });
280
+ this.triggerEarlyFlushIfNeeded();
281
+ return true;
282
+ } catch (error) {
283
+ this.logger.warn(() => ({
284
+ msg: "Failed to queue session record in Redis",
285
+ err: error,
286
+ sessionId
287
+ }));
288
+ return false;
289
+ }
290
+ }
291
+ /**
292
+ * Get all pending session records ready for batch flush.
293
+ */
294
+ async drainSessionRecords(limit = 500) {
295
+ const client = await this.getClient();
296
+ if (!client) return [];
297
+ try {
298
+ const batch = (await client.sMembers("writeq:session:pending")).slice(0, limit);
299
+ const results = [];
300
+ for (const sessionId of batch) {
301
+ const key = `writeq:session:${sessionId}`;
302
+ const data = await client.get(key);
303
+ if (data) results.push({
304
+ sessionId,
305
+ record: JSON.parse(data, bigIntReviver)
306
+ });
307
+ await client.sRem("writeq:session:pending", sessionId);
308
+ await client.del(key);
309
+ }
310
+ return results;
311
+ } catch (error) {
312
+ this.logger.warn(() => ({
313
+ msg: "Failed to drain session records from Redis",
314
+ err: error
315
+ }));
316
+ return [];
317
+ }
318
+ }
319
+ /**
320
+ * Start periodic background flush of queued records.
321
+ * The callback receives this queue instance and should drain + bulk-write records.
322
+ *
323
+ * When the queue depth exceeds `earlyFlushThreshold`, an early flush is
324
+ * triggered on the next `queueSessionRecord` call without waiting for
325
+ * the regular interval.
326
+ */
327
+ startPeriodicFlush(callback, intervalMs = 1e4, earlyFlushThreshold = 50) {
328
+ this.stopPeriodicFlush();
329
+ this.flushCallback = callback;
330
+ this.earlyFlushThreshold = earlyFlushThreshold;
331
+ this.flushTimer = setInterval(() => {
332
+ this.flushCallback?.(this).catch((error) => {
333
+ this.logger.error(() => ({
334
+ msg: "Periodic flush failed",
335
+ err: error
336
+ }));
337
+ });
338
+ }, intervalMs);
339
+ }
340
+ /**
341
+ * Trigger an early flush if the pending queue exceeds the threshold.
342
+ * Called automatically after queueing a record. This avoids large
343
+ * batch build-ups between regular interval flushes.
344
+ */
345
+ triggerEarlyFlushIfNeeded() {
346
+ if (!this.flushCallback || !this.earlyFlushThreshold) return;
347
+ this.getPendingCount().then((count) => {
348
+ if (count >= (this.earlyFlushThreshold ?? 50)) return this.flushCallback?.(this);
349
+ }).catch((error) => {
350
+ this.logger.error(() => ({
351
+ msg: "Early flush failed",
352
+ err: error
353
+ }));
354
+ });
355
+ }
356
+ /** Get the number of pending session records awaiting flush. */
357
+ async getPendingCount() {
358
+ const client = await this.getClient();
359
+ if (!client) return 0;
360
+ try {
361
+ return await client.sCard("writeq:session:pending");
362
+ } catch {
363
+ return 0;
364
+ }
365
+ }
366
+ /**
367
+ * Stop the periodic flush timer.
368
+ */
369
+ stopPeriodicFlush() {
370
+ if (this.flushTimer) {
371
+ clearInterval(this.flushTimer);
372
+ this.flushTimer = null;
373
+ }
374
+ this.flushCallback = null;
375
+ this.earlyFlushThreshold = void 0;
376
+ }
377
+ /**
378
+ * Drop every session-related key from Redis. Intended for provider
379
+ * startup so a fresh process can never inherit stale read-cache,
380
+ * dedup-by-hash, or pending-write-queue entries written by an
381
+ * earlier (possibly crashed) run.
382
+ *
383
+ * Uses SCAN — KEYS would block the Redis server on large keyspaces.
384
+ *
385
+ * Bypasses the `isReady()` fast-path that other methods use because
386
+ * this runs during startup, before the Redis client's async
387
+ * connect() handshake has had time to flip the flag. We await the
388
+ * connection promise directly with a bounded timeout so we don't
389
+ * hang the boot sequence when Redis is unreachable.
390
+ */
391
+ async clearAllSessionRecords(timeoutMs = 5e3) {
392
+ this.logger.info(() => ({ msg: "Clearing Redis session records at startup" }));
393
+ let client;
394
+ try {
395
+ client = await Promise.race([this.connection.getClient(), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Redis connection timeout")), timeoutMs))]);
396
+ } catch (error) {
397
+ this.logger.warn(() => ({
398
+ msg: "Skipped Redis session cleanup — Redis not reachable",
399
+ err: error
400
+ }));
401
+ return;
402
+ }
403
+ try {
404
+ let totalDeleted = 0;
405
+ for (const pattern of SESSION_KEY_PATTERNS) for await (const key of client.scanIterator({
406
+ MATCH: pattern,
407
+ COUNT: 500
408
+ })) {
409
+ const keys = Array.isArray(key) ? key : [key];
410
+ if (keys.length > 0) {
411
+ await client.del(keys);
412
+ totalDeleted += keys.length;
413
+ }
414
+ }
415
+ this.logger.info(() => ({
416
+ msg: "Cleared Redis session records at startup",
417
+ data: { totalDeleted }
418
+ }));
419
+ } catch (error) {
420
+ this.logger.warn(() => ({
421
+ msg: "Failed to clear Redis session records at startup",
422
+ err: error
423
+ }));
424
+ }
425
+ }
426
+ /**
427
+ * Check if the underlying Redis connection is ready.
428
+ */
429
+ isReady() {
430
+ return this.connection.isReady();
431
+ }
432
+ };
433
+ //#endregion
476
434
  exports.RedisWriteQueue = RedisWriteQueue;