@tumbaland/backend-core 1.20.0 → 1.22.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.
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connects once and throws on failure. There is deliberately no retry loop here.
|
|
3
|
+
*
|
|
4
|
+
* MongoDB is Atlas (`mongodb+srv://`), not a sidecar container, so there is no
|
|
5
|
+
* boot race to ride out — and the driver already retries for us: `connect()`
|
|
6
|
+
* keeps doing server selection for `serverSelectionTimeoutMS` (30s by default)
|
|
7
|
+
* before it rejects, so a transient DNS or network blip is absorbed there.
|
|
8
|
+
* Anything that survives that is a real fault — bad credentials, an IP missing
|
|
9
|
+
* from the Atlas allowlist, a dead cluster — and no amount of retrying fixes it.
|
|
10
|
+
*
|
|
11
|
+
* Callers exit non-zero when this throws, which hands the problem to the
|
|
12
|
+
* container restart policy (`restart: unless-stopped`) that already supervises
|
|
13
|
+
* every service. One supervisor, and a service that can't reach its database
|
|
14
|
+
* shows up as a restarting container instead of a healthy one serving errors.
|
|
15
|
+
*/
|
|
1
16
|
export declare const connectDB: (serviceName?: string, uri?: string) => Promise<void>;
|
|
2
17
|
export declare const disconnectDB: (serviceName?: string) => Promise<void>;
|
|
3
18
|
//# 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":"AAWA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,SAAS,GAAU,cAAc,MAAM,EAAE,MAAM,MAAM,KAAG,OAAO,CAAC,IAAI,CAchF,CAAC;AAwCF,eAAO,MAAM,YAAY,GAAU,cAAc,MAAM,KAAG,OAAO,CAAC,IAAI,CASrE,CAAC"}
|
|
@@ -7,33 +7,76 @@ exports.disconnectDB = exports.connectDB = void 0;
|
|
|
7
7
|
const mongoose_1 = __importDefault(require("mongoose"));
|
|
8
8
|
const logger_1 = __importDefault(require("../logging/logger"));
|
|
9
9
|
let connected = false;
|
|
10
|
+
/**
|
|
11
|
+
* Set while a connection attempt is in flight so concurrent callers await that
|
|
12
|
+
* attempt instead of opening a second one against the same mongoose singleton.
|
|
13
|
+
*/
|
|
14
|
+
let pending = null;
|
|
15
|
+
let listenersRegistered = false;
|
|
16
|
+
/**
|
|
17
|
+
* Connects once and throws on failure. There is deliberately no retry loop here.
|
|
18
|
+
*
|
|
19
|
+
* MongoDB is Atlas (`mongodb+srv://`), not a sidecar container, so there is no
|
|
20
|
+
* boot race to ride out — and the driver already retries for us: `connect()`
|
|
21
|
+
* keeps doing server selection for `serverSelectionTimeoutMS` (30s by default)
|
|
22
|
+
* before it rejects, so a transient DNS or network blip is absorbed there.
|
|
23
|
+
* Anything that survives that is a real fault — bad credentials, an IP missing
|
|
24
|
+
* from the Atlas allowlist, a dead cluster — and no amount of retrying fixes it.
|
|
25
|
+
*
|
|
26
|
+
* Callers exit non-zero when this throws, which hands the problem to the
|
|
27
|
+
* container restart policy (`restart: unless-stopped`) that already supervises
|
|
28
|
+
* every service. One supervisor, and a service that can't reach its database
|
|
29
|
+
* shows up as a restarting container instead of a healthy one serving errors.
|
|
30
|
+
*/
|
|
10
31
|
const connectDB = async (serviceName, uri) => {
|
|
11
32
|
if (connected)
|
|
12
33
|
return;
|
|
34
|
+
if (pending)
|
|
35
|
+
return pending;
|
|
13
36
|
const mongoUri = uri || process.env.MONGODB_URI;
|
|
14
37
|
if (!mongoUri) {
|
|
15
38
|
throw new Error('MONGODB_URI environment variable is not set');
|
|
16
39
|
}
|
|
40
|
+
pending = connectOnce(mongoUri, serviceName).finally(() => {
|
|
41
|
+
pending = null;
|
|
42
|
+
});
|
|
43
|
+
return pending;
|
|
44
|
+
};
|
|
45
|
+
exports.connectDB = connectDB;
|
|
46
|
+
async function connectOnce(mongoUri, serviceName) {
|
|
17
47
|
try {
|
|
18
48
|
await mongoose_1.default.connect(mongoUri);
|
|
19
49
|
connected = true;
|
|
50
|
+
registerConnectionListeners(serviceName);
|
|
20
51
|
logger_1.default.info('MongoDB connected', { service: serviceName });
|
|
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
52
|
}
|
|
31
53
|
catch (error) {
|
|
32
|
-
logger_1.default.error('Failed to connect to MongoDB', {
|
|
54
|
+
logger_1.default.error('Failed to connect to MongoDB', {
|
|
55
|
+
service: serviceName,
|
|
56
|
+
error: error.message
|
|
57
|
+
});
|
|
33
58
|
throw error;
|
|
34
59
|
}
|
|
35
|
-
}
|
|
36
|
-
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mongoose reconnects on its own once an initial connection has succeeded, so
|
|
63
|
+
* these only report state — but without them a drop is silent and the only
|
|
64
|
+
* symptom is the health endpoint flipping to "disconnected".
|
|
65
|
+
*/
|
|
66
|
+
function registerConnectionListeners(serviceName) {
|
|
67
|
+
if (listenersRegistered)
|
|
68
|
+
return;
|
|
69
|
+
listenersRegistered = true;
|
|
70
|
+
mongoose_1.default.connection.on('error', (err) => {
|
|
71
|
+
logger_1.default.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
72
|
+
});
|
|
73
|
+
mongoose_1.default.connection.on('disconnected', () => {
|
|
74
|
+
logger_1.default.warn('MongoDB disconnected', { service: serviceName });
|
|
75
|
+
});
|
|
76
|
+
mongoose_1.default.connection.on('reconnected', () => {
|
|
77
|
+
logger_1.default.info('MongoDB reconnected', { service: serviceName });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
37
80
|
const disconnectDB = async (serviceName) => {
|
|
38
81
|
try {
|
|
39
82
|
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;AAEvC,IAAI,SAAS,GAAG,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tumbaland/backend-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.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,7 +9,7 @@
|
|
|
9
9
|
"dev": "tsc --watch",
|
|
10
10
|
"clean": "rm -rf dist",
|
|
11
11
|
"prepublishOnly": "npm run clean && npm run build",
|
|
12
|
-
"release": "commit-and-tag-version && npm run build && npm publish --access public",
|
|
12
|
+
"release": "commit-and-tag-version && npm run build && npm publish --access public && node ../../scripts/sync-lib-versions.mjs",
|
|
13
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",
|
|
@@ -23,10 +23,15 @@ import logger from '../logging/logger';
|
|
|
23
23
|
import { disconnectDB } from './connection';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* `connection.ts` keeps
|
|
27
|
-
* scenario needs its own fresh copy of the module
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* `connection.ts` keeps module-level `connected`/`pending` flags, so each
|
|
27
|
+
* connectDB scenario needs its own fresh copy of the module —
|
|
28
|
+
* isolateModulesAsync sandboxes that without leaking into other tests in this
|
|
29
|
+
* file the way a blanket jest.resetModules() would.
|
|
30
|
+
*
|
|
31
|
+
* Note that this does NOT re-create the mongoose/logger mocks: a jest.mock
|
|
32
|
+
* factory result is shared across isolated registries, so the objects returned
|
|
33
|
+
* here are the same instances every time. Resetting their implementations is
|
|
34
|
+
* beforeEach's job — see the comment there.
|
|
30
35
|
*/
|
|
31
36
|
async function freshConnectDB() {
|
|
32
37
|
let connectDBFn!: typeof import('./connection').connectDB;
|
|
@@ -49,6 +54,12 @@ describe('connectDB', () => {
|
|
|
49
54
|
process.env = { ...ORIGINAL_ENV };
|
|
50
55
|
delete process.env.MONGODB_URI;
|
|
51
56
|
for (const key of Object.keys(connectionHandlers)) delete connectionHandlers[key];
|
|
57
|
+
|
|
58
|
+
// `clearMocks: true` only clears recorded calls, not implementations, so a
|
|
59
|
+
// mockRejectedValue set by one test stays installed for every test after
|
|
60
|
+
// it. Reset connect explicitly — but not connection.on, whose factory
|
|
61
|
+
// implementation is what records into connectionHandlers.
|
|
62
|
+
(mongoose.connect as jest.Mock).mockReset();
|
|
52
63
|
});
|
|
53
64
|
|
|
54
65
|
afterAll(() => {
|
|
@@ -101,6 +112,41 @@ describe('connectDB', () => {
|
|
|
101
112
|
});
|
|
102
113
|
});
|
|
103
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The driver retries internally for serverSelectionTimeoutMS; anything that
|
|
117
|
+
* reaches us is a real fault, so one failure is one rejection. Callers exit
|
|
118
|
+
* non-zero and the container restart policy takes it from there.
|
|
119
|
+
*/
|
|
120
|
+
it('does not retry a failed connection', async () => {
|
|
121
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
122
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
123
|
+
(mongoose.connect as jest.Mock).mockRejectedValue(new Error('auth failed'));
|
|
124
|
+
|
|
125
|
+
await expect(connectDB('my-service')).rejects.toThrow('auth failed');
|
|
126
|
+
|
|
127
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('clears the in-flight promise so a later call can retry', async () => {
|
|
131
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
132
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
133
|
+
(mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
|
|
134
|
+
|
|
135
|
+
await expect(connectDB('my-service')).rejects.toThrow('not ready');
|
|
136
|
+
await connectDB('my-service');
|
|
137
|
+
|
|
138
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('lets concurrent callers share one connection attempt', async () => {
|
|
142
|
+
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
143
|
+
const { connectDB, mongoose } = await freshConnectDB();
|
|
144
|
+
|
|
145
|
+
await Promise.all([connectDB('my-service'), connectDB('my-service')]);
|
|
146
|
+
|
|
147
|
+
expect(mongoose.connect).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
104
150
|
it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
|
|
105
151
|
process.env.MONGODB_URI = 'mongodb://env-uri';
|
|
106
152
|
const { connectDB, logger } = await freshConnectDB();
|
|
@@ -2,37 +2,81 @@ import mongoose from 'mongoose';
|
|
|
2
2
|
import logger from '../logging/logger';
|
|
3
3
|
|
|
4
4
|
let connected = false;
|
|
5
|
+
/**
|
|
6
|
+
* Set while a connection attempt is in flight so concurrent callers await that
|
|
7
|
+
* attempt instead of opening a second one against the same mongoose singleton.
|
|
8
|
+
*/
|
|
9
|
+
let pending: Promise<void> | null = null;
|
|
10
|
+
let listenersRegistered = false;
|
|
5
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Connects once and throws on failure. There is deliberately no retry loop here.
|
|
14
|
+
*
|
|
15
|
+
* MongoDB is Atlas (`mongodb+srv://`), not a sidecar container, so there is no
|
|
16
|
+
* boot race to ride out — and the driver already retries for us: `connect()`
|
|
17
|
+
* keeps doing server selection for `serverSelectionTimeoutMS` (30s by default)
|
|
18
|
+
* before it rejects, so a transient DNS or network blip is absorbed there.
|
|
19
|
+
* Anything that survives that is a real fault — bad credentials, an IP missing
|
|
20
|
+
* from the Atlas allowlist, a dead cluster — and no amount of retrying fixes it.
|
|
21
|
+
*
|
|
22
|
+
* Callers exit non-zero when this throws, which hands the problem to the
|
|
23
|
+
* container restart policy (`restart: unless-stopped`) that already supervises
|
|
24
|
+
* every service. One supervisor, and a service that can't reach its database
|
|
25
|
+
* shows up as a restarting container instead of a healthy one serving errors.
|
|
26
|
+
*/
|
|
6
27
|
export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
|
|
7
28
|
if (connected) return;
|
|
29
|
+
if (pending) return pending;
|
|
8
30
|
|
|
9
31
|
const mongoUri = uri || process.env.MONGODB_URI;
|
|
10
32
|
if (!mongoUri) {
|
|
11
33
|
throw new Error('MONGODB_URI environment variable is not set');
|
|
12
34
|
}
|
|
13
35
|
|
|
36
|
+
pending = connectOnce(mongoUri, serviceName).finally(() => {
|
|
37
|
+
pending = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return pending;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function connectOnce(mongoUri: string, serviceName: string | undefined): Promise<void> {
|
|
14
44
|
try {
|
|
15
45
|
await mongoose.connect(mongoUri);
|
|
16
46
|
connected = true;
|
|
17
47
|
|
|
48
|
+
registerConnectionListeners(serviceName);
|
|
18
49
|
logger.info('MongoDB connected', { service: serviceName });
|
|
19
|
-
|
|
20
|
-
mongoose.connection.on('error', (err: Error) => {
|
|
21
|
-
logger.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
mongoose.connection.on('disconnected', () => {
|
|
25
|
-
logger.warn('MongoDB disconnected', { service: serviceName });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
mongoose.connection.on('reconnected', () => {
|
|
29
|
-
logger.info('MongoDB reconnected', { service: serviceName });
|
|
30
|
-
});
|
|
31
50
|
} catch (error) {
|
|
32
|
-
logger.error('Failed to connect to MongoDB', {
|
|
51
|
+
logger.error('Failed to connect to MongoDB', {
|
|
52
|
+
service: serviceName,
|
|
53
|
+
error: (error as Error).message
|
|
54
|
+
});
|
|
33
55
|
throw error;
|
|
34
56
|
}
|
|
35
|
-
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mongoose reconnects on its own once an initial connection has succeeded, so
|
|
61
|
+
* these only report state — but without them a drop is silent and the only
|
|
62
|
+
* symptom is the health endpoint flipping to "disconnected".
|
|
63
|
+
*/
|
|
64
|
+
function registerConnectionListeners(serviceName?: string): void {
|
|
65
|
+
if (listenersRegistered) return;
|
|
66
|
+
listenersRegistered = true;
|
|
67
|
+
|
|
68
|
+
mongoose.connection.on('error', (err: Error) => {
|
|
69
|
+
logger.error('MongoDB connection error', { service: serviceName, error: err.message });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
mongoose.connection.on('disconnected', () => {
|
|
73
|
+
logger.warn('MongoDB disconnected', { service: serviceName });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
mongoose.connection.on('reconnected', () => {
|
|
77
|
+
logger.info('MongoDB reconnected', { service: serviceName });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
36
80
|
|
|
37
81
|
export const disconnectDB = async (serviceName?: string): Promise<void> => {
|
|
38
82
|
try {
|