request-scope-api 1.0.21 → 1.0.23

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.
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * ShutdownManager — Manages graceful shutdown of all RequestScope resources.
4
+ *
5
+ * This centralizes shutdown logic to prevent multiple shutdown executions,
6
+ * add comprehensive logging, and expose a shutdown() method that the host
7
+ * application can call instead of relying on global signal handlers.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.shutdownManager = void 0;
11
+ const adapter_singleton_js_1 = require("./adapter-singleton.js");
12
+ class ShutdownManager {
13
+ constructor() {
14
+ this.isShuttingDown = false;
15
+ this.resources = {};
16
+ }
17
+ /**
18
+ * Registers resources that need to be cleaned up during shutdown.
19
+ */
20
+ registerResources(resources) {
21
+ this.resources = { ...this.resources, ...resources };
22
+ }
23
+ /**
24
+ * Performs graceful shutdown of all registered resources.
25
+ *
26
+ * Steps:
27
+ * 1. Prevents multiple shutdown executions
28
+ * 2. Stops retention scheduler
29
+ * 3. Drains queue with timeout
30
+ * 4. Destroys queue
31
+ * 5. Closes all database connections
32
+ * 6. Logs remaining active handles for debugging
33
+ */
34
+ async shutdown(options = {}) {
35
+ const { drainTimeoutMs = 5000, logHandles = false } = options;
36
+ // Prevent multiple shutdown executions
37
+ if (this.isShuttingDown) {
38
+ process.stderr.write('[RequestScope] Shutdown already in progress, skipping duplicate call\n');
39
+ return;
40
+ }
41
+ this.isShuttingDown = true;
42
+ process.stderr.write('[RequestScope] Starting graceful shutdown...\n');
43
+ const startTime = Date.now();
44
+ try {
45
+ // 1. Stop retention scheduler
46
+ if (this.resources.retentionScheduler) {
47
+ process.stderr.write('[RequestScope] Stopping retention scheduler...\n');
48
+ this.resources.retentionScheduler.stop();
49
+ }
50
+ // 2. Drain queue with timeout
51
+ if (this.resources.queue) {
52
+ process.stderr.write(`[RequestScope] Draining queue (timeout: ${drainTimeoutMs}ms)...\n`);
53
+ try {
54
+ await Promise.race([
55
+ this.resources.queue.drain(drainTimeoutMs),
56
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Queue drain timeout')), drainTimeoutMs))
57
+ ]);
58
+ process.stderr.write('[RequestScope] Queue drained successfully\n');
59
+ }
60
+ catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ process.stderr.write(`[RequestScope] Queue drain warning: ${message}\n`);
63
+ }
64
+ // 3. Destroy queue (clears timer)
65
+ process.stderr.write('[RequestScope] Destroying queue...\n');
66
+ this.resources.queue.destroy();
67
+ }
68
+ // 4. Close all database connections
69
+ process.stderr.write('[RequestScope] Closing database connections...\n');
70
+ await adapter_singleton_js_1.adapterSingleton.closeAll();
71
+ process.stderr.write('[RequestScope] Database connections closed\n');
72
+ const duration = Date.now() - startTime;
73
+ process.stderr.write(`[RequestScope] Graceful shutdown completed in ${duration}ms\n`);
74
+ // 5. Log remaining active handles for debugging
75
+ if (logHandles) {
76
+ this.logActiveHandles();
77
+ }
78
+ }
79
+ catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ process.stderr.write(`[RequestScope] Shutdown error: ${message}\n`);
82
+ // Log active handles even on error
83
+ if (logHandles) {
84
+ this.logActiveHandles();
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Logs active handles to help identify what's preventing process exit.
90
+ */
91
+ logActiveHandles() {
92
+ try {
93
+ // @ts-ignore - _getActiveHandles is not in TypeScript definitions
94
+ const handles = process._getActiveHandles();
95
+ process.stderr.write(`[RequestScope] Active handles (${handles.length}):\n`);
96
+ for (let i = 0; i < Math.min(handles.length, 20); i++) {
97
+ const handle = handles[i];
98
+ const type = handle.constructor.name;
99
+ // @ts-ignore
100
+ const details = handle._handle?.type || '';
101
+ process.stderr.write(` [${i}] ${type}${details ? ` (${details})` : ''}\n`);
102
+ }
103
+ if (handles.length > 20) {
104
+ process.stderr.write(` ... and ${handles.length - 20} more\n`);
105
+ }
106
+ }
107
+ catch (err) {
108
+ process.stderr.write('[RequestScope] Could not log active handles\n');
109
+ }
110
+ }
111
+ /**
112
+ * Returns whether shutdown is currently in progress.
113
+ */
114
+ isShutdownInProgress() {
115
+ return this.isShuttingDown;
116
+ }
117
+ }
118
+ // Export singleton instance
119
+ exports.shutdownManager = new ShutdownManager();
120
+ //# sourceMappingURL=shutdown-manager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shutdown-manager.js","sourceRoot":"","sources":["../../src/shutdown-manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * AdapterSingleton — Manages singleton database adapter instances.
3
+ *
4
+ * Ensures that only one database connection/pool exists per unique configuration
5
+ * across the entire application lifecycle. This prevents:
6
+ * - Multiple connection pools when middleware is called multiple times
7
+ * - Duplicate connections when setup function is imported multiple times
8
+ * - Connection leaks on server restarts in dev mode (nodemon)
9
+ *
10
+ * The singleton is keyed by a hash of the storage configuration, so different
11
+ * configurations will have separate connections (as expected).
12
+ */
13
+ import { MongoAdapter } from './adapters/mongo.adapter.js';
14
+ import { MySQLAdapter } from './adapters/mysql.adapter.js';
15
+ import { PgAdapter } from './adapters/pg.adapter.js';
16
+ // ---------------------------------------------------------------------------
17
+ // Configuration hash helper
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Creates a stable hash string from storage configuration.
21
+ * This is used as the key for singleton instances.
22
+ */
23
+ function hashStorageConfig(config) {
24
+ const parts = [config.type];
25
+ if (config.type === 'mongodb') {
26
+ parts.push(config.uri || '');
27
+ }
28
+ else if (config.type === 'mysql' || config.type === 'postgresql') {
29
+ parts.push(config.host || 'localhost', String(config.port || (config.type === 'mysql' ? 3306 : 5432)), config.database || '', config.username || '');
30
+ }
31
+ return parts.join(':');
32
+ }
33
+ class AdapterSingletonManager {
34
+ constructor() {
35
+ this.instances = new Map();
36
+ }
37
+ /**
38
+ * Gets or creates a singleton adapter instance for the given configuration.
39
+ *
40
+ * @param config - Storage configuration
41
+ * @returns StorageAdapter instance (shared if already exists)
42
+ */
43
+ getOrCreate(config) {
44
+ const hash = hashStorageConfig(config);
45
+ const existing = this.instances.get(hash);
46
+ if (existing) {
47
+ // Increment reference count
48
+ existing.refCount++;
49
+ return existing.adapter;
50
+ }
51
+ // Create new adapter instance
52
+ let adapter;
53
+ switch (config.type) {
54
+ case 'mongodb':
55
+ adapter = new MongoAdapter(config);
56
+ break;
57
+ case 'mysql':
58
+ adapter = new MySQLAdapter(config);
59
+ break;
60
+ case 'postgresql':
61
+ adapter = new PgAdapter(config);
62
+ break;
63
+ default:
64
+ throw new Error(`Unsupported storage type: ${config.type}`);
65
+ }
66
+ // Store in singleton map
67
+ this.instances.set(hash, {
68
+ adapter,
69
+ refCount: 1,
70
+ initialized: false,
71
+ });
72
+ return adapter;
73
+ }
74
+ /**
75
+ * Marks an adapter as initialized.
76
+ * This is called after successful adapter.initialize().
77
+ */
78
+ markInitialized(adapter, config) {
79
+ const hash = hashStorageConfig(config);
80
+ const entry = this.instances.get(hash);
81
+ if (entry && entry.adapter === adapter) {
82
+ entry.initialized = true;
83
+ }
84
+ }
85
+ /**
86
+ * Decrements reference count for an adapter.
87
+ * When reference count reaches zero, the adapter is cleaned up.
88
+ *
89
+ * @param adapter - StorageAdapter instance to release
90
+ * @param config - Storage configuration used to create the adapter
91
+ */
92
+ release(adapter, config) {
93
+ const hash = hashStorageConfig(config);
94
+ const entry = this.instances.get(hash);
95
+ if (entry && entry.adapter === adapter) {
96
+ entry.refCount--;
97
+ // Clean up if no more references
98
+ if (entry.refCount <= 0) {
99
+ this.instances.delete(hash);
100
+ // Close the connection to prevent connection leaks
101
+ void adapter.close().catch((err) => {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ process.stderr.write(`[RequestScope] Error closing adapter: ${message}\n`);
104
+ });
105
+ }
106
+ }
107
+ }
108
+ /**
109
+ * Gets the current number of singleton instances (for debugging).
110
+ */
111
+ getInstanceCount() {
112
+ return this.instances.size;
113
+ }
114
+ /**
115
+ * Clears all singleton instances.
116
+ * This should only be used in tests or on application shutdown.
117
+ */
118
+ clearAll() {
119
+ this.instances.clear();
120
+ }
121
+ /**
122
+ * Closes all adapter connections and clears the singleton instances.
123
+ * This should be called during application graceful shutdown.
124
+ */
125
+ async closeAll() {
126
+ const closePromises = [];
127
+ for (const [hash, entry] of this.instances.entries()) {
128
+ closePromises.push(entry.adapter.close().catch((err) => {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ process.stderr.write(`[RequestScope] Error closing adapter [${hash}]: ${message}\n`);
131
+ }));
132
+ }
133
+ await Promise.all(closePromises);
134
+ this.instances.clear();
135
+ }
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Export singleton instance
139
+ // ---------------------------------------------------------------------------
140
+ export const adapterSingleton = new AdapterSingletonManager();
@@ -180,4 +180,14 @@ export class MongoAdapter {
180
180
  });
181
181
  return result.deletedCount;
182
182
  }
183
+ // -------------------------------------------------------------------------
184
+ // close()
185
+ // -------------------------------------------------------------------------
186
+ async close() {
187
+ if (this.client) {
188
+ await this.client.close();
189
+ this.client = null;
190
+ this.collection = null;
191
+ }
192
+ }
183
193
  }
@@ -224,6 +224,15 @@ export class MySQLAdapter {
224
224
  return result.affectedRows;
225
225
  }
226
226
  // -------------------------------------------------------------------------
227
+ // close()
228
+ // -------------------------------------------------------------------------
229
+ async close() {
230
+ if (this.pool) {
231
+ await this.pool.end();
232
+ this.pool = null;
233
+ }
234
+ }
235
+ // -------------------------------------------------------------------------
227
236
  // Private helpers
228
237
  // -------------------------------------------------------------------------
229
238
  getPool() {
@@ -320,6 +320,15 @@ export class PgAdapter {
320
320
  return result.rowCount ?? 0;
321
321
  }
322
322
  // -------------------------------------------------------------------------
323
+ // close()
324
+ // -------------------------------------------------------------------------
325
+ async close() {
326
+ if (this.pool) {
327
+ await this.pool.end();
328
+ this.pool = null;
329
+ }
330
+ }
331
+ // -------------------------------------------------------------------------
323
332
  // Private helpers
324
333
  // -------------------------------------------------------------------------
325
334
  getPool() {
package/dist/esm/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import express from 'express';
12
12
  import { requestscope as requestscopeMiddleware, errorHandler, setup } from './middleware.js';
13
13
  import { createDashboardRouter } from './dashboard/router.js';
14
+ import { shutdownManager } from './shutdown-manager.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Main middleware factory
16
17
  // ---------------------------------------------------------------------------
@@ -55,6 +56,26 @@ export { setup };
55
56
  // ---------------------------------------------------------------------------
56
57
  export { errorHandler };
57
58
  // ---------------------------------------------------------------------------
59
+ // Shutdown function
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Gracefully shuts down all RequestScope resources.
63
+ *
64
+ * This should be called by the host application during shutdown (e.g., on SIGTERM/SIGINT).
65
+ * It will:
66
+ * - Stop the retention scheduler
67
+ * - Drain the queue with a timeout
68
+ * - Destroy the queue (clear timers)
69
+ * - Close all database connections
70
+ *
71
+ * @param options - Optional configuration for shutdown
72
+ * @param options.drainTimeoutMs - Timeout for queue drain in milliseconds (default: 5000)
73
+ * @param options.logHandles - Whether to log active handles after shutdown (default: false)
74
+ */
75
+ export async function shutdown(options) {
76
+ await shutdownManager.shutdown(options);
77
+ }
78
+ // ---------------------------------------------------------------------------
58
79
  // Named exports for convenience
59
80
  // ---------------------------------------------------------------------------
60
81
  export { requestscope as requestscopeMiddleware } from './middleware.js';
@@ -11,13 +11,12 @@
11
11
  * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.2, 4.1, 4.2, 4.3, 5.1
12
12
  */
13
13
  import { validateConfig, applyDefaults, buildSensitiveFieldSet } from './config.js';
14
- import { MongoAdapter } from './adapters/mongo.adapter.js';
15
- import { MySQLAdapter } from './adapters/mysql.adapter.js';
16
- import { PgAdapter } from './adapters/pg.adapter.js';
17
14
  import { AsyncQueue } from './queue.js';
18
15
  import { RetentionScheduler } from './retention.js';
19
16
  import { bufferRequestBody, wrapResponse, normalizeRequestUrl, ERROR_DATA_SYMBOL, } from './capture.js';
20
17
  import { createDashboardRouter } from './dashboard/router.js';
18
+ import { adapterSingleton } from './adapter-singleton.js';
19
+ import { shutdownManager } from './shutdown-manager.js';
21
20
  const ALWAYS_IGNORED_PATHS = ['/favicon.ico', '/requestscope/api', '/requestscope/assets'];
22
21
  // ---------------------------------------------------------------------------
23
22
  // Factory function
@@ -56,25 +55,15 @@ export function requestscope(config, adapter) {
56
55
  validateConfig(config);
57
56
  applyDefaults(config);
58
57
  const sensitiveFields = buildSensitiveFieldSet(config);
59
- // 2. Instantiate the appropriate storage adapter (if not provided)
58
+ // 2. Get or create singleton adapter instance
60
59
  if (!adapter) {
61
- switch (config.storage.type) {
62
- case 'mongodb':
63
- adapter = new MongoAdapter(config.storage);
64
- break;
65
- case 'mysql':
66
- adapter = new MySQLAdapter(config.storage);
67
- break;
68
- case 'postgresql':
69
- adapter = new PgAdapter(config.storage);
70
- break;
71
- default:
72
- // This should never happen due to validateConfig, but TypeScript needs it
73
- throw new Error(`Unsupported storage type: ${config.storage.type}`);
74
- }
60
+ adapter = adapterSingleton.getOrCreate(config.storage);
75
61
  }
76
62
  // 3. Initialize adapter asynchronously (log errors, don't throw)
77
- void adapter.initialize().catch((err) => {
63
+ // Only initialize if not already initialized
64
+ void adapter.initialize().then(() => {
65
+ adapterSingleton.markInitialized(adapter, config.storage);
66
+ }).catch((err) => {
78
67
  const message = err instanceof Error ? err.message : String(err);
79
68
  process.stderr.write(`[RequestScope] Failed to initialize storage adapter: ${message}\n`);
80
69
  });
@@ -83,23 +72,16 @@ export function requestscope(config, adapter) {
83
72
  const retentionDays = config.retentionDays ?? 30;
84
73
  const retentionScheduler = new RetentionScheduler(adapter, retentionDays);
85
74
  retentionScheduler.start();
86
- // 5. Attach graceful shutdown handlers (only once per process)
87
- const shutdown = async () => {
88
- retentionScheduler.stop();
89
- await queue.drain(10000);
90
- queue.destroy();
91
- };
92
- // Use a flag to ensure shutdown handlers are only attached once
93
- const shutdownKey = '__requestscope_shutdown_attached__';
94
- if (!process[shutdownKey]) {
95
- process[shutdownKey] = true;
96
- process.on('SIGTERM', () => {
97
- void shutdown();
98
- });
99
- process.on('SIGINT', () => {
100
- void shutdown();
101
- });
102
- }
75
+ // 5. Register resources with shutdown manager
76
+ shutdownManager.registerResources({
77
+ queue: {
78
+ drain: (timeoutMs) => queue.drain(timeoutMs),
79
+ destroy: () => queue.destroy(),
80
+ },
81
+ retentionScheduler: {
82
+ stop: () => retentionScheduler.stop(),
83
+ },
84
+ });
103
85
  // 6. Return the request handler middleware
104
86
  return async (req, res, next) => {
105
87
  // Check ignore list
@@ -153,23 +135,12 @@ export function setup(app, config) {
153
135
  // Validate and apply defaults
154
136
  validateConfig(config);
155
137
  applyDefaults(config);
156
- // Instantiate the appropriate storage adapter
157
- let adapter;
158
- switch (config.storage.type) {
159
- case 'mongodb':
160
- adapter = new MongoAdapter(config.storage);
161
- break;
162
- case 'mysql':
163
- adapter = new MySQLAdapter(config.storage);
164
- break;
165
- case 'postgresql':
166
- adapter = new PgAdapter(config.storage);
167
- break;
168
- default:
169
- throw new Error(`Unsupported storage type: ${config.storage.type}`);
170
- }
138
+ // Get or create singleton adapter instance
139
+ const adapter = adapterSingleton.getOrCreate(config.storage);
171
140
  // Initialize adapter asynchronously
172
- void adapter.initialize().catch((err) => {
141
+ void adapter.initialize().then(() => {
142
+ adapterSingleton.markInitialized(adapter, config.storage);
143
+ }).catch((err) => {
173
144
  const message = err instanceof Error ? err.message : String(err);
174
145
  process.stderr.write(`[RequestScope] Failed to initialize storage adapter: ${message}\n`);
175
146
  });
@@ -27,6 +27,10 @@ export class RetentionScheduler {
27
27
  this.intervalHandle = setInterval(() => {
28
28
  void this.runOnce();
29
29
  }, TWENTY_FOUR_HOURS_MS);
30
+ // Allow the Node.js process to exit even if this timer is still active.
31
+ if (this.intervalHandle.unref) {
32
+ this.intervalHandle.unref();
33
+ }
30
34
  }
31
35
  /**
32
36
  * Stops the scheduled retention job. Inflight `runOnce()` calls are allowed
@@ -0,0 +1,116 @@
1
+ /**
2
+ * ShutdownManager — Manages graceful shutdown of all RequestScope resources.
3
+ *
4
+ * This centralizes shutdown logic to prevent multiple shutdown executions,
5
+ * add comprehensive logging, and expose a shutdown() method that the host
6
+ * application can call instead of relying on global signal handlers.
7
+ */
8
+ import { adapterSingleton } from './adapter-singleton.js';
9
+ class ShutdownManager {
10
+ constructor() {
11
+ this.isShuttingDown = false;
12
+ this.resources = {};
13
+ }
14
+ /**
15
+ * Registers resources that need to be cleaned up during shutdown.
16
+ */
17
+ registerResources(resources) {
18
+ this.resources = { ...this.resources, ...resources };
19
+ }
20
+ /**
21
+ * Performs graceful shutdown of all registered resources.
22
+ *
23
+ * Steps:
24
+ * 1. Prevents multiple shutdown executions
25
+ * 2. Stops retention scheduler
26
+ * 3. Drains queue with timeout
27
+ * 4. Destroys queue
28
+ * 5. Closes all database connections
29
+ * 6. Logs remaining active handles for debugging
30
+ */
31
+ async shutdown(options = {}) {
32
+ const { drainTimeoutMs = 5000, logHandles = false } = options;
33
+ // Prevent multiple shutdown executions
34
+ if (this.isShuttingDown) {
35
+ process.stderr.write('[RequestScope] Shutdown already in progress, skipping duplicate call\n');
36
+ return;
37
+ }
38
+ this.isShuttingDown = true;
39
+ process.stderr.write('[RequestScope] Starting graceful shutdown...\n');
40
+ const startTime = Date.now();
41
+ try {
42
+ // 1. Stop retention scheduler
43
+ if (this.resources.retentionScheduler) {
44
+ process.stderr.write('[RequestScope] Stopping retention scheduler...\n');
45
+ this.resources.retentionScheduler.stop();
46
+ }
47
+ // 2. Drain queue with timeout
48
+ if (this.resources.queue) {
49
+ process.stderr.write(`[RequestScope] Draining queue (timeout: ${drainTimeoutMs}ms)...\n`);
50
+ try {
51
+ await Promise.race([
52
+ this.resources.queue.drain(drainTimeoutMs),
53
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Queue drain timeout')), drainTimeoutMs))
54
+ ]);
55
+ process.stderr.write('[RequestScope] Queue drained successfully\n');
56
+ }
57
+ catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ process.stderr.write(`[RequestScope] Queue drain warning: ${message}\n`);
60
+ }
61
+ // 3. Destroy queue (clears timer)
62
+ process.stderr.write('[RequestScope] Destroying queue...\n');
63
+ this.resources.queue.destroy();
64
+ }
65
+ // 4. Close all database connections
66
+ process.stderr.write('[RequestScope] Closing database connections...\n');
67
+ await adapterSingleton.closeAll();
68
+ process.stderr.write('[RequestScope] Database connections closed\n');
69
+ const duration = Date.now() - startTime;
70
+ process.stderr.write(`[RequestScope] Graceful shutdown completed in ${duration}ms\n`);
71
+ // 5. Log remaining active handles for debugging
72
+ if (logHandles) {
73
+ this.logActiveHandles();
74
+ }
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ process.stderr.write(`[RequestScope] Shutdown error: ${message}\n`);
79
+ // Log active handles even on error
80
+ if (logHandles) {
81
+ this.logActiveHandles();
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Logs active handles to help identify what's preventing process exit.
87
+ */
88
+ logActiveHandles() {
89
+ try {
90
+ // @ts-ignore - _getActiveHandles is not in TypeScript definitions
91
+ const handles = process._getActiveHandles();
92
+ process.stderr.write(`[RequestScope] Active handles (${handles.length}):\n`);
93
+ for (let i = 0; i < Math.min(handles.length, 20); i++) {
94
+ const handle = handles[i];
95
+ const type = handle.constructor.name;
96
+ // @ts-ignore
97
+ const details = handle._handle?.type || '';
98
+ process.stderr.write(` [${i}] ${type}${details ? ` (${details})` : ''}\n`);
99
+ }
100
+ if (handles.length > 20) {
101
+ process.stderr.write(` ... and ${handles.length - 20} more\n`);
102
+ }
103
+ }
104
+ catch (err) {
105
+ process.stderr.write('[RequestScope] Could not log active handles\n');
106
+ }
107
+ }
108
+ /**
109
+ * Returns whether shutdown is currently in progress.
110
+ */
111
+ isShutdownInProgress() {
112
+ return this.isShuttingDown;
113
+ }
114
+ }
115
+ // Export singleton instance
116
+ export const shutdownManager = new ShutdownManager();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * AdapterSingleton — Manages singleton database adapter instances.
3
+ *
4
+ * Ensures that only one database connection/pool exists per unique configuration
5
+ * across the entire application lifecycle. This prevents:
6
+ * - Multiple connection pools when middleware is called multiple times
7
+ * - Duplicate connections when setup function is imported multiple times
8
+ * - Connection leaks on server restarts in dev mode (nodemon)
9
+ *
10
+ * The singleton is keyed by a hash of the storage configuration, so different
11
+ * configurations will have separate connections (as expected).
12
+ */
13
+ import type { StorageAdapter, StorageConfig } from './types.js';
14
+ declare class AdapterSingletonManager {
15
+ private instances;
16
+ /**
17
+ * Gets or creates a singleton adapter instance for the given configuration.
18
+ *
19
+ * @param config - Storage configuration
20
+ * @returns StorageAdapter instance (shared if already exists)
21
+ */
22
+ getOrCreate(config: StorageConfig): StorageAdapter;
23
+ /**
24
+ * Marks an adapter as initialized.
25
+ * This is called after successful adapter.initialize().
26
+ */
27
+ markInitialized(adapter: StorageAdapter, config: StorageConfig): void;
28
+ /**
29
+ * Decrements reference count for an adapter.
30
+ * When reference count reaches zero, the adapter is cleaned up.
31
+ *
32
+ * @param adapter - StorageAdapter instance to release
33
+ * @param config - Storage configuration used to create the adapter
34
+ */
35
+ release(adapter: StorageAdapter, config: StorageConfig): void;
36
+ /**
37
+ * Gets the current number of singleton instances (for debugging).
38
+ */
39
+ getInstanceCount(): number;
40
+ /**
41
+ * Clears all singleton instances.
42
+ * This should only be used in tests or on application shutdown.
43
+ */
44
+ clearAll(): void;
45
+ /**
46
+ * Closes all adapter connections and clears the singleton instances.
47
+ * This should be called during application graceful shutdown.
48
+ */
49
+ closeAll(): Promise<void>;
50
+ }
51
+ export declare const adapterSingleton: AdapterSingletonManager;
52
+ export {};
@@ -22,4 +22,5 @@ export declare class MongoAdapter implements StorageAdapter {
22
22
  total: number;
23
23
  }>;
24
24
  deleteOlderThan(date: Date): Promise<number>;
25
+ close(): Promise<void>;
25
26
  }
@@ -20,5 +20,6 @@ export declare class MySQLAdapter implements StorageAdapter {
20
20
  total: number;
21
21
  }>;
22
22
  deleteOlderThan(date: Date): Promise<number>;
23
+ close(): Promise<void>;
23
24
  private getPool;
24
25
  }
@@ -25,5 +25,6 @@ export declare class PgAdapter implements StorageAdapter {
25
25
  total: number;
26
26
  }>;
27
27
  deleteOlderThan(date: Date): Promise<number>;
28
+ close(): Promise<void>;
28
29
  private getPool;
29
30
  }