@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":"AAKA,eAAO,MAAM,SAAS,GAAU,cAAc,MAAM,EAAE,MAAM,MAAM,KAAG,OAAO,CAAC,IAAI,CA6BhF,CAAC;AAEF,eAAO,MAAM,YAAY,GAAU,cAAc,MAAM,KAAG,OAAO,CAAC,IAAI,CASrE,CAAC"}
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', { service: serviceName, error: error.message });
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
- exports.connectDB = connectDB;
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;AAEf,MAAM,SAAS,GAAG,KAAK,EAAE,WAAoB,EAAE,GAAY,EAAiB,EAAE;IACnF,IAAI,SAAS;QAAE,OAAO;IAEtB,MAAM,QAAQ,GAAG,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IAChD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACjE,CAAC;IAED,IAAI,CAAC;QACH,MAAM,kBAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QACjC,SAAS,GAAG,IAAI,CAAC;QAEjB,gBAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;QAE3D,kBAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAU,EAAE,EAAE;YAC7C,gBAAM,CAAC,KAAK,CAAC,0BAA0B,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;QACzF,CAAC,CAAC,CAAC;QAEH,kBAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,cAAc,EAAE,GAAG,EAAE;YAC1C,gBAAM,CAAC,IAAI,CAAC,sBAAsB,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;QAChE,CAAC,CAAC,CAAC;QAEH,kBAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,aAAa,EAAE,GAAG,EAAE;YACzC,gBAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;QAC/D,CAAC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,gBAAM,CAAC,KAAK,CAAC,8BAA8B,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAG,KAAe,CAAC,OAAO,EAAE,CAAC,CAAC;QACxG,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AA7BW,QAAA,SAAS,aA6BpB;AAEK,MAAM,YAAY,GAAG,KAAK,EAAE,WAAoB,EAAiB,EAAE;IACxE,IAAI,CAAC;QACH,MAAM,kBAAQ,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QAClC,SAAS,GAAG,KAAK,CAAC;QAClB,gBAAM,CAAC,IAAI,CAAC,2BAA2B,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IACrE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,gBAAM,CAAC,KAAK,CAAC,kCAAkC,EAAE,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAG,KAAe,CAAC,OAAO,EAAE,CAAC,CAAC;QAC5G,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AATW,QAAA,YAAY,gBASvB"}
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.20.0",
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 a module-level `connected` flag, so each connectDB
27
- * scenario needs its own fresh copy of the module (and of its mongoose/logger
28
- * mocks) — isolateModulesAsync sandboxes that without leaking into other
29
- * tests in this file the way a blanket jest.resetModules() would.
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', { service: serviceName, error: (error as Error).message });
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 {