@tumbaland/backend-core 1.19.0 → 1.21.0
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/dist/database/connection.d.ts +12 -1
- package/dist/database/connection.d.ts.map +1 -1
- package/dist/database/connection.js +73 -19
- package/dist/database/connection.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/database/connection.test.ts +57 -3
- package/src/database/connection.ts +98 -18
|
@@ -1,3 +1,14 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface ConnectDBOptions {
|
|
2
|
+
/**
|
|
3
|
+
* How many times to try before giving up. Defaults to Infinity: a service that
|
|
4
|
+
* boots before the host's network is up must keep trying, not die once.
|
|
5
|
+
*/
|
|
6
|
+
maxAttempts?: number;
|
|
7
|
+
/** Backoff delay for the first retry. Doubles per attempt up to maxDelayMs. */
|
|
8
|
+
initialDelayMs?: number;
|
|
9
|
+
/** Ceiling for the backoff delay. */
|
|
10
|
+
maxDelayMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const connectDB: (serviceName?: string, uri?: string, options?: ConnectDBOptions) => Promise<void>;
|
|
2
13
|
export declare const disconnectDB: (serviceName?: string) => Promise<void>;
|
|
3
14
|
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qCAAqC;IACrC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAeD,eAAO,MAAM,SAAS,GACpB,cAAc,MAAM,EACpB,MAAM,MAAM,EACZ,UAAS,gBAAqB,KAC7B,OAAO,CAAC,IAAI,CAgBd,CAAC;AAoEF,eAAO,MAAM,YAAY,GAAU,cAAc,MAAM,KAAG,OAAO,CAAC,IAAI,CASrE,CAAC"}
|
|
@@ -6,34 +6,88 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.disconnectDB = exports.connectDB = void 0;
|
|
7
7
|
const mongoose_1 = __importDefault(require("mongoose"));
|
|
8
8
|
const logger_1 = __importDefault(require("../logging/logger"));
|
|
9
|
+
const DEFAULT_INITIAL_DELAY_MS = 1000;
|
|
10
|
+
const DEFAULT_MAX_DELAY_MS = 30000;
|
|
9
11
|
let connected = false;
|
|
10
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Set while a retry loop is running so concurrent callers await that loop
|
|
14
|
+
* instead of starting a second one against the same mongoose singleton.
|
|
15
|
+
*/
|
|
16
|
+
let pending = null;
|
|
17
|
+
let listenersRegistered = false;
|
|
18
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
const connectDB = async (serviceName, uri, options = {}) => {
|
|
11
20
|
if (connected)
|
|
12
21
|
return;
|
|
22
|
+
if (pending)
|
|
23
|
+
return pending;
|
|
13
24
|
const mongoUri = uri || process.env.MONGODB_URI;
|
|
25
|
+
// A missing URI is a config error, not a transient one — retrying can never
|
|
26
|
+
// fix it, so fail immediately rather than looping forever on a typo.
|
|
14
27
|
if (!mongoUri) {
|
|
15
28
|
throw new Error('MONGODB_URI environment variable is not set');
|
|
16
29
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
mongoose_1.default.connection.on('error', (err) => {
|
|
22
|
-
logger_1.default.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
23
|
-
});
|
|
24
|
-
mongoose_1.default.connection.on('disconnected', () => {
|
|
25
|
-
logger_1.default.warn('MongoDB disconnected', { service: serviceName });
|
|
26
|
-
});
|
|
27
|
-
mongoose_1.default.connection.on('reconnected', () => {
|
|
28
|
-
logger_1.default.info('MongoDB reconnected', { service: serviceName });
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
logger_1.default.error('Failed to connect to MongoDB', { service: serviceName, error: error.message });
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
30
|
+
pending = connectWithRetry(mongoUri, serviceName, options).finally(() => {
|
|
31
|
+
pending = null;
|
|
32
|
+
});
|
|
33
|
+
return pending;
|
|
35
34
|
};
|
|
36
35
|
exports.connectDB = connectDB;
|
|
36
|
+
async function connectWithRetry(mongoUri, serviceName, options) {
|
|
37
|
+
const maxAttempts = options.maxAttempts ?? Infinity;
|
|
38
|
+
const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|
|
39
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
40
|
+
for (let attempt = 1;; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
await mongoose_1.default.connect(mongoUri);
|
|
43
|
+
connected = true;
|
|
44
|
+
registerConnectionListeners(serviceName);
|
|
45
|
+
logger_1.default.info('MongoDB connected', { service: serviceName });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const message = error.message;
|
|
50
|
+
if (attempt >= maxAttempts) {
|
|
51
|
+
logger_1.default.error('Failed to connect to MongoDB, giving up', {
|
|
52
|
+
service: serviceName,
|
|
53
|
+
attempt,
|
|
54
|
+
error: message
|
|
55
|
+
});
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
// Exponential backoff, capped: a Mongo outage lasting hours shouldn't
|
|
59
|
+
// grow the gap past maxDelayMs and leave the service down long after
|
|
60
|
+
// the database comes back.
|
|
61
|
+
const delayMs = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
|
62
|
+
logger_1.default.warn('Failed to connect to MongoDB, retrying', {
|
|
63
|
+
service: serviceName,
|
|
64
|
+
attempt,
|
|
65
|
+
delayMs,
|
|
66
|
+
error: message
|
|
67
|
+
});
|
|
68
|
+
await sleep(delayMs);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Mongoose reconnects on its own once an initial connection has succeeded, so
|
|
74
|
+
* these only report state — but without them a drop is silent and the only
|
|
75
|
+
* symptom is the health endpoint flipping to "disconnected".
|
|
76
|
+
*/
|
|
77
|
+
function registerConnectionListeners(serviceName) {
|
|
78
|
+
if (listenersRegistered)
|
|
79
|
+
return;
|
|
80
|
+
listenersRegistered = true;
|
|
81
|
+
mongoose_1.default.connection.on('error', (err) => {
|
|
82
|
+
logger_1.default.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
83
|
+
});
|
|
84
|
+
mongoose_1.default.connection.on('disconnected', () => {
|
|
85
|
+
logger_1.default.warn('MongoDB disconnected', { service: serviceName });
|
|
86
|
+
});
|
|
87
|
+
mongoose_1.default.connection.on('reconnected', () => {
|
|
88
|
+
logger_1.default.info('MongoDB reconnected', { service: serviceName });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
37
91
|
const disconnectDB = async (serviceName) => {
|
|
38
92
|
try {
|
|
39
93
|
await mongoose_1.default.connection.close();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":";;;;;;AAAA,wDAAgC;AAChC,+DAAuC;
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../src/middleware/security.ts"],"names":[],"mappings":"AACA,OAAkB,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE5E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,eAAe,
|
|
1
|
+
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../src/middleware/security.ts"],"names":[],"mappings":"AACA,OAAkB,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE5E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,eAAe,gGA4BshH,CAAC,2BAxBjjH,CAAC;AAEH;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,OAAO,CAAC,gBAAgB,CAAM,wDASxE;AAED,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,sDAAsB,CAAC;AAEvD;;;GAGG;AACH,eAAO,MAAM,iBAAiB,sDAAiC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tumbaland/backend-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "Core shared functionality for Tumbaland backend services",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"dev": "tsc --watch",
|
|
10
10
|
"clean": "rm -rf dist",
|
|
11
11
|
"prepublishOnly": "npm run clean && npm run build",
|
|
12
|
-
"release": "
|
|
13
|
-
"release:beta": "
|
|
12
|
+
"release": "commit-and-tag-version && npm run build && npm publish --access public && node ../../scripts/sync-lib-versions.mjs",
|
|
13
|
+
"release:beta": "commit-and-tag-version --prerelease beta && npm run build && npm publish --access public --tag beta",
|
|
14
14
|
"test": "jest",
|
|
15
15
|
"test:coverage": "jest --coverage",
|
|
16
16
|
"lint": "eslint .",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"author": "Tumbaland Team",
|
|
27
27
|
"license": "MIT",
|
|
28
|
-
"
|
|
28
|
+
"commit-and-tag-version": {
|
|
29
29
|
"tagPrefix": "backend-core-v"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@types/jsonwebtoken": "^9.0.10",
|
|
36
36
|
"@types/morgan": "^1.9.10",
|
|
37
37
|
"@types/node": "^26.1.0",
|
|
38
|
-
"
|
|
38
|
+
"commit-and-tag-version": "^13.1.2",
|
|
39
39
|
"typescript": "^6.0.3"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
@@ -89,18 +89,72 @@ describe('connectDB', () => {
|
|
|
89
89
|
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it('logs and rethrows when mongoose.connect fails', async () => {
|
|
92
|
+
it('logs and rethrows when mongoose.connect fails on the last allowed attempt', async () => {
|
|
93
93
|
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
94
94
|
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
95
95
|
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
96
96
|
|
|
97
|
-
await expect(connectDB('my-service')).rejects.toThrow('ECONNREFUSED');
|
|
98
|
-
expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB', {
|
|
97
|
+
await expect(connectDB('my-service', undefined, { maxAttempts: 1 })).rejects.toThrow('ECONNREFUSED');
|
|
98
|
+
expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB, giving up', {
|
|
99
99
|
service: 'my-service',
|
|
100
|
+
attempt: 1,
|
|
100
101
|
error: 'ECONNREFUSED'
|
|
101
102
|
});
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
it('retries a failing connection until it succeeds', async () => {
|
|
106
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
107
|
+
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
108
|
+
(mongoose.connect as jest.Mock)
|
|
109
|
+
.mockRejectedValueOnce(new Error('getaddrinfo EAI_AGAIN'))
|
|
110
|
+
.mockRejectedValueOnce(new Error('getaddrinfo EAI_AGAIN'));
|
|
111
|
+
|
|
112
|
+
await connectDB('my-service', undefined, { initialDelayMs: 0 });
|
|
113
|
+
|
|
114
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(3);
|
|
115
|
+
expect(logger.warn).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(logger.info).toHaveBeenCalledWith('MongoDB connected', { service: 'my-service' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('gives up after maxAttempts', async () => {
|
|
120
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
121
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
122
|
+
(mongoose.connect as jest.Mock).mockRejectedValue(new Error('auth failed'));
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
connectDB('my-service', undefined, { maxAttempts: 3, initialDelayMs: 0 })
|
|
126
|
+
).rejects.toThrow('auth failed');
|
|
127
|
+
|
|
128
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(3);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('caps the backoff delay at maxDelayMs', async () => {
|
|
132
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
133
|
+
const { connectDB, mongoose, logger } = await freshConnectDB();
|
|
134
|
+
(mongoose.connect as jest.Mock)
|
|
135
|
+
.mockRejectedValueOnce(new Error('down'))
|
|
136
|
+
.mockRejectedValueOnce(new Error('down'))
|
|
137
|
+
.mockRejectedValueOnce(new Error('down'));
|
|
138
|
+
|
|
139
|
+
await connectDB('my-service', undefined, { initialDelayMs: 1, maxDelayMs: 2 });
|
|
140
|
+
|
|
141
|
+
const delays = (logger.warn as jest.Mock).mock.calls.map((call) => call[1].delayMs);
|
|
142
|
+
expect(delays).toEqual([1, 2, 2]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('lets concurrent callers share one retry loop', async () => {
|
|
146
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
147
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
148
|
+
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
|
|
149
|
+
|
|
150
|
+
await Promise.all([
|
|
151
|
+
connectDB('my-service', undefined, { initialDelayMs: 0 }),
|
|
152
|
+
connectDB('my-service', undefined, { initialDelayMs: 0 })
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
104
158
|
it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
|
|
105
159
|
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
106
160
|
const { connectDB, logger } = await freshConnectDB();
|
|
@@ -1,38 +1,118 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
2
|
import logger from '../logging/logger';
|
|
3
3
|
|
|
4
|
+
export interface ConnectDBOptions {
|
|
5
|
+
/**
|
|
6
|
+
* How many times to try before giving up. Defaults to Infinity: a service that
|
|
7
|
+
* boots before the host's network is up must keep trying, not die once.
|
|
8
|
+
*/
|
|
9
|
+
maxAttempts?: number;
|
|
10
|
+
/** Backoff delay for the first retry. Doubles per attempt up to maxDelayMs. */
|
|
11
|
+
initialDelayMs?: number;
|
|
12
|
+
/** Ceiling for the backoff delay. */
|
|
13
|
+
maxDelayMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_INITIAL_DELAY_MS = 1000;
|
|
17
|
+
const DEFAULT_MAX_DELAY_MS = 30000;
|
|
18
|
+
|
|
4
19
|
let connected = false;
|
|
20
|
+
/**
|
|
21
|
+
* Set while a retry loop is running so concurrent callers await that loop
|
|
22
|
+
* instead of starting a second one against the same mongoose singleton.
|
|
23
|
+
*/
|
|
24
|
+
let pending: Promise<void> | null = null;
|
|
25
|
+
let listenersRegistered = false;
|
|
5
26
|
|
|
6
|
-
|
|
27
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
|
|
29
|
+
export const connectDB = async (
|
|
30
|
+
serviceName?: string,
|
|
31
|
+
uri?: string,
|
|
32
|
+
options: ConnectDBOptions = {}
|
|
33
|
+
): Promise<void> => {
|
|
7
34
|
if (connected) return;
|
|
35
|
+
if (pending) return pending;
|
|
8
36
|
|
|
9
37
|
const mongoUri = uri || process.env.MONGODB_URI;
|
|
38
|
+
// A missing URI is a config error, not a transient one — retrying can never
|
|
39
|
+
// fix it, so fail immediately rather than looping forever on a typo.
|
|
10
40
|
if (!mongoUri) {
|
|
11
41
|
throw new Error('MONGODB_URI environment variable is not set');
|
|
12
42
|
}
|
|
13
43
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
44
|
+
pending = connectWithRetry(mongoUri, serviceName, options).finally(() => {
|
|
45
|
+
pending = null;
|
|
46
|
+
});
|
|
17
47
|
|
|
18
|
-
|
|
48
|
+
return pending;
|
|
49
|
+
};
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
51
|
+
async function connectWithRetry(
|
|
52
|
+
mongoUri: string,
|
|
53
|
+
serviceName: string | undefined,
|
|
54
|
+
options: ConnectDBOptions
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const maxAttempts = options.maxAttempts ?? Infinity;
|
|
57
|
+
const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|
|
58
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
23
59
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
60
|
+
for (let attempt = 1; ; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
await mongoose.connect(mongoUri);
|
|
63
|
+
connected = true;
|
|
27
64
|
|
|
28
|
-
|
|
29
|
-
logger.info('MongoDB
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
65
|
+
registerConnectionListeners(serviceName);
|
|
66
|
+
logger.info('MongoDB connected', { service: serviceName });
|
|
67
|
+
return;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const message = (error as Error).message;
|
|
70
|
+
|
|
71
|
+
if (attempt >= maxAttempts) {
|
|
72
|
+
logger.error('Failed to connect to MongoDB, giving up', {
|
|
73
|
+
service: serviceName,
|
|
74
|
+
attempt,
|
|
75
|
+
error: message
|
|
76
|
+
});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Exponential backoff, capped: a Mongo outage lasting hours shouldn't
|
|
81
|
+
// grow the gap past maxDelayMs and leave the service down long after
|
|
82
|
+
// the database comes back.
|
|
83
|
+
const delayMs = Math.min(initialDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
|
84
|
+
logger.warn('Failed to connect to MongoDB, retrying', {
|
|
85
|
+
service: serviceName,
|
|
86
|
+
attempt,
|
|
87
|
+
delayMs,
|
|
88
|
+
error: message
|
|
89
|
+
});
|
|
90
|
+
await sleep(delayMs);
|
|
91
|
+
}
|
|
34
92
|
}
|
|
35
|
-
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mongoose reconnects on its own once an initial connection has succeeded, so
|
|
97
|
+
* these only report state — but without them a drop is silent and the only
|
|
98
|
+
* symptom is the health endpoint flipping to "disconnected".
|
|
99
|
+
*/
|
|
100
|
+
function registerConnectionListeners(serviceName?: string): void {
|
|
101
|
+
if (listenersRegistered) return;
|
|
102
|
+
listenersRegistered = true;
|
|
103
|
+
|
|
104
|
+
mongoose.connection.on('error', (err: Error) => {
|
|
105
|
+
logger.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
mongoose.connection.on('disconnected', () => {
|
|
109
|
+
logger.warn('MongoDB disconnected', { service: serviceName });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
mongoose.connection.on('reconnected', () => {
|
|
113
|
+
logger.info('MongoDB reconnected', { service: serviceName });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
36
116
|
|
|
37
117
|
export const disconnectDB = async (serviceName?: string): Promise<void> => {
|
|
38
118
|
try {
|