@tumbaland/backend-core 1.21.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,14 +1,18 @@
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>;
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
+ */
16
+ export declare const connectDB: (serviceName?: string, uri?: string) => Promise<void>;
13
17
  export declare const disconnectDB: (serviceName?: string) => Promise<void>;
14
18
  //# sourceMappingURL=connection.d.ts.map
@@ -1 +1 @@
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"}
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"}
@@ -6,67 +6,56 @@ 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;
11
9
  let connected = false;
12
10
  /**
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.
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.
15
13
  */
16
14
  let pending = null;
17
15
  let listenersRegistered = false;
18
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
- const connectDB = async (serviceName, uri, options = {}) => {
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
+ */
31
+ const connectDB = async (serviceName, uri) => {
20
32
  if (connected)
21
33
  return;
22
34
  if (pending)
23
35
  return pending;
24
36
  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.
27
37
  if (!mongoUri) {
28
38
  throw new Error('MONGODB_URI environment variable is not set');
29
39
  }
30
- pending = connectWithRetry(mongoUri, serviceName, options).finally(() => {
40
+ pending = connectOnce(mongoUri, serviceName).finally(() => {
31
41
  pending = null;
32
42
  });
33
43
  return pending;
34
44
  };
35
45
  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
- }
46
+ async function connectOnce(mongoUri, serviceName) {
47
+ try {
48
+ await mongoose_1.default.connect(mongoUri);
49
+ connected = true;
50
+ registerConnectionListeners(serviceName);
51
+ logger_1.default.info('MongoDB connected', { service: serviceName });
52
+ }
53
+ catch (error) {
54
+ logger_1.default.error('Failed to connect to MongoDB', {
55
+ service: serviceName,
56
+ error: error.message
57
+ });
58
+ throw error;
70
59
  }
71
60
  }
72
61
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/database/connection.ts"],"names":[],"mappings":"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"}
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.21.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",
@@ -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(() => {
@@ -89,70 +100,51 @@ describe('connectDB', () => {
89
100
  expect(mongoose.connect).toHaveBeenCalledTimes(1);
90
101
  });
91
102
 
92
- it('logs and rethrows when mongoose.connect fails on the last allowed attempt', async () => {
103
+ it('logs and rethrows when mongoose.connect fails', async () => {
93
104
  process.env.MONGODB_URI = 'mongodb://env-uri';
94
105
  const { connectDB, mongoose, logger } = await freshConnectDB();
95
106
  (mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
96
107
 
97
- await expect(connectDB('my-service', undefined, { maxAttempts: 1 })).rejects.toThrow('ECONNREFUSED');
98
- expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB, giving up', {
108
+ await expect(connectDB('my-service')).rejects.toThrow('ECONNREFUSED');
109
+ expect(logger.error).toHaveBeenCalledWith('Failed to connect to MongoDB', {
99
110
  service: 'my-service',
100
- attempt: 1,
101
111
  error: 'ECONNREFUSED'
102
112
  });
103
113
  });
104
114
 
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 () => {
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 () => {
120
121
  process.env.MONGODB_URI = 'mongodb://env-uri';
121
122
  const { connectDB, mongoose } = await freshConnectDB();
122
123
  (mongoose.connect as jest.Mock).mockRejectedValue(new Error('auth failed'));
123
124
 
124
- await expect(
125
- connectDB('my-service', undefined, { maxAttempts: 3, initialDelayMs: 0 })
126
- ).rejects.toThrow('auth failed');
125
+ await expect(connectDB('my-service')).rejects.toThrow('auth failed');
127
126
 
128
- expect(mongoose.connect).toHaveBeenCalledTimes(3);
127
+ expect(mongoose.connect).toHaveBeenCalledTimes(1);
129
128
  });
130
129
 
131
- it('caps the backoff delay at maxDelayMs', async () => {
130
+ it('clears the in-flight promise so a later call can retry', async () => {
132
131
  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'));
132
+ const { connectDB, mongoose } = await freshConnectDB();
133
+ (mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
138
134
 
139
- await connectDB('my-service', undefined, { initialDelayMs: 1, maxDelayMs: 2 });
135
+ await expect(connectDB('my-service')).rejects.toThrow('not ready');
136
+ await connectDB('my-service');
140
137
 
141
- const delays = (logger.warn as jest.Mock).mock.calls.map((call) => call[1].delayMs);
142
- expect(delays).toEqual([1, 2, 2]);
138
+ expect(mongoose.connect).toHaveBeenCalledTimes(2);
143
139
  });
144
140
 
145
- it('lets concurrent callers share one retry loop', async () => {
141
+ it('lets concurrent callers share one connection attempt', async () => {
146
142
  process.env.MONGODB_URI = 'mongodb://env-uri';
147
143
  const { connectDB, mongoose } = await freshConnectDB();
148
- (mongoose.connect as jest.Mock).mockRejectedValueOnce(new Error('not ready'));
149
144
 
150
- await Promise.all([
151
- connectDB('my-service', undefined, { initialDelayMs: 0 }),
152
- connectDB('my-service', undefined, { initialDelayMs: 0 })
153
- ]);
145
+ await Promise.all([connectDB('my-service'), connectDB('my-service')]);
154
146
 
155
- expect(mongoose.connect).toHaveBeenCalledTimes(2);
147
+ expect(mongoose.connect).toHaveBeenCalledTimes(1);
156
148
  });
157
149
 
158
150
  it('registers error/disconnected/reconnected handlers that log through the shared logger', async () => {
@@ -1,94 +1,58 @@
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
-
19
4
  let connected = false;
20
5
  /**
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.
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.
23
8
  */
24
9
  let pending: Promise<void> | null = null;
25
10
  let listenersRegistered = false;
26
11
 
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> => {
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
+ */
27
+ export const connectDB = async (serviceName?: string, uri?: string): Promise<void> => {
34
28
  if (connected) return;
35
29
  if (pending) return pending;
36
30
 
37
31
  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.
40
32
  if (!mongoUri) {
41
33
  throw new Error('MONGODB_URI environment variable is not set');
42
34
  }
43
35
 
44
- pending = connectWithRetry(mongoUri, serviceName, options).finally(() => {
36
+ pending = connectOnce(mongoUri, serviceName).finally(() => {
45
37
  pending = null;
46
38
  });
47
39
 
48
40
  return pending;
49
41
  };
50
42
 
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;
59
-
60
- for (let attempt = 1; ; attempt++) {
61
- try {
62
- await mongoose.connect(mongoUri);
63
- connected = true;
64
-
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
- }
43
+ async function connectOnce(mongoUri: string, serviceName: string | undefined): Promise<void> {
44
+ try {
45
+ await mongoose.connect(mongoUri);
46
+ connected = true;
79
47
 
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
- }
48
+ registerConnectionListeners(serviceName);
49
+ logger.info('MongoDB connected', { service: serviceName });
50
+ } catch (error) {
51
+ logger.error('Failed to connect to MongoDB', {
52
+ service: serviceName,
53
+ error: (error as Error).message
54
+ });
55
+ throw error;
92
56
  }
93
57
  }
94
58