@tumbaland/backend-core 1.20.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.
@@ -1,3 +1,14 @@
1
- export declare const connectDB: (serviceName?: string, uri?: string) => Promise<void>;
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":"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":"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
- const connectDB = async (serviceName, uri) => {
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
- try {
18
- await mongoose_1.default.connect(mongoUri);
19
- connected = true;
20
- 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
- }
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;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.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,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",
@@ -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
- export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
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
- try {
15
- await mongoose.connect(mongoUri);
16
- connected = true;
44
+ pending = connectWithRetry(mongoUri, serviceName, options).finally(() => {
45
+ pending = null;
46
+ });
17
47
 
18
- logger.info('MongoDB connected', { service: serviceName });
48
+ return pending;
49
+ };
19
50
 
20
- mongoose.connection.on('error', (err: Error) => {
21
- logger.error('MongoDB connection error', { service: serviceName, error: err.message });
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
- mongoose.connection.on('disconnected', () => {
25
- logger.warn('MongoDB disconnected', { service: serviceName });
26
- });
60
+ for (let attempt = 1; ; attempt++) {
61
+ try {
62
+ await mongoose.connect(mongoUri);
63
+ connected = true;
27
64
 
28
- mongoose.connection.on('reconnected', () => {
29
- logger.info('MongoDB reconnected', { service: serviceName });
30
- });
31
- } catch (error) {
32
- logger.error('Failed to connect to MongoDB', { service: serviceName, error: (error as Error).message });
33
- throw error;
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 {