mongodb-ops 0.11.2 → 0.11.3

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.
@@ -3,6 +3,55 @@
3
3
  const MongoClient = require('mongodb').MongoClient;
4
4
  const ObjectID = require('mongodb').ObjectId;
5
5
 
6
+ const loggedTopologyErrors = new WeakSet();
7
+
8
+ /**
9
+ * Redact URI and userinfo content before flattening or truncating diagnostic text.
10
+ * Host addresses and replica-set names from topology fields are intentionally allowed.
11
+ * @param {*} value
12
+ * @returns {string}
13
+ */
14
+ function diagnosticText(value) {
15
+ return String(value)
16
+ .replace(/\b[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi, '[REDACTED_URI]')
17
+ .replace(/[^\s"'<>/:@]+:[^\s"'<>/@]+@/g, '[REDACTED_USERINFO]@')
18
+ .replace(/\s+/g, ' ');
19
+ }
20
+
21
+ /**
22
+ * Summarize each known host's type, round-trip time and last error, if available.
23
+ * @param {Error} err
24
+ * @returns {string|null}
25
+ */
26
+ function describeTopologyReason(err) {
27
+ const reason = err && typeof err === 'object' ? err.reason : undefined;
28
+ if (!reason || !(reason.servers instanceof Map)) { return null; }
29
+ const hosts = Array.from(reason.servers, ([address, sd]) => {
30
+ const hostErr = sd && sd.error;
31
+ const detail = hostErr ? diagnosticText(`${hostErr.name || 'Error'}: ${hostErr.message || hostErr}`).slice(0, 200) : 'none';
32
+ return `${diagnosticText(address)} type=${diagnosticText(sd && sd.type)} rtt=${diagnosticText(sd && sd.roundTripTime)} error=${detail}`;
33
+ });
34
+ return `topology=${diagnosticText(reason.type)} setName=${diagnosticText(reason.setName || 'none')} servers=${reason.servers.size} | ${hosts.join(' ; ')}`;
35
+ }
36
+
37
+ /**
38
+ * Log a topology-bearing error once without ever replacing the caller's rejection.
39
+ * @param {Error} err
40
+ * @param {string} where
41
+ * @returns {void}
42
+ */
43
+ function logTopologyReason(err, where) {
44
+ try {
45
+ if (loggedTopologyErrors.has(err)) { return; }
46
+ const summary = describeTopologyReason(err);
47
+ if (!summary) { return; }
48
+ loggedTopologyErrors.add(err);
49
+ console.error(`[mongodb-ops] ${where} ${diagnosticText(err.name)}: ${diagnosticText(err.message)} | ${summary}`);
50
+ } catch {
51
+ // Diagnostics must not break the caller or connection recovery.
52
+ }
53
+ }
54
+
6
55
  class MongoDBOps {
7
56
  /**
8
57
  * @class
@@ -53,6 +102,7 @@ class MongoDBOps {
53
102
  if (MongoDBOps.dbClients.get(connString) === clientPromise) {
54
103
  MongoDBOps.dbClients.delete(connString);
55
104
  }
105
+ logTopologyReason(err, 'connect');
56
106
  throw err;
57
107
  });
58
108
  MongoDBOps.dbClients.set(connString, clientPromise);
@@ -281,7 +331,10 @@ class MongoDBOps {
281
331
  }
282
332
  return Promise.resolve(result);
283
333
  }
284
- catch (err) { return Promise.reject(err); }
334
+ catch (err) {
335
+ logTopologyReason(err, 'writeData');
336
+ return Promise.reject(err);
337
+ }
285
338
  }
286
339
 
287
340
  /**
@@ -364,7 +417,10 @@ class MongoDBOps {
364
417
  result = await db.collection(collectionName).bulkWrite(docs, { ordered: ordered });
365
418
  return Promise.resolve(result);
366
419
  }
367
- catch (err) { return Promise.reject(err); }
420
+ catch (err) {
421
+ logTopologyReason(err, 'writeBulkData');
422
+ return Promise.reject(err);
423
+ }
368
424
  }
369
425
 
370
426
  /**
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mongodb-ops",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Read and write ops for MongoDB",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test",
8
- "lint": "node --check index.js && node --check lib/mongodb-ops.js && node --check test/mongodb-ops.test.js && node --check test/mongodb-write-errors.test.js"
8
+ "lint": "node --check index.js && node --check lib/mongodb-ops.js && node --check test/mongodb-ops.test.js && node --check test/mongodb-write-errors.test.js && node --check test/mongodb-topology-diagnostics.test.js"
9
9
  },
10
10
  "dependencies": {
11
11
  "mongodb": "^6.8.0"
package/readme.md CHANGED
@@ -112,6 +112,13 @@ Constructor: `new MongoDBToolSet(collectionName, connString)`. Instance methods
112
112
 
113
113
  ## Changelog
114
114
 
115
+ ### 0.11.3
116
+ - **Topology diagnostics:** errors carrying a topology (`MongoServerSelectionError.reason`) emit one `[mongodb-ops] <connect|writeData|writeBulkData>` line through `console.error`, listing each host's type, round-trip time and last error. Host addresses and replica-set `setName` are intentionally logged; complete URIs and userinfo are redacted from diagnostic text. Connection strings are never passed to the logger.
117
+ - Diagnostics cannot replace the original rejection, even if formatting or logging throws. Failed connections are evicted before logging so the next call can reconnect.
118
+
119
+ ### 0.11.2
120
+ - **Preserve write errors:** single-write and bulk-write failures now reject the original `Error`, retaining `code`, `codeName`, `errInfo` and bulk `writeErrors`. This replaces the previous string / `BulkWriteResult` rejection shapes; consumers should read the error's `message` rather than assume a string.
121
+
115
122
  ### 0.11.1
116
123
  - **Hardened connection caching.** The client cache is now keyed by connection string in a `Map` (previously matched against MongoDB driver internals), making client reuse reliable across driver versions.
117
124
  - **De-duplicated concurrent cold-start connects** by caching the connect promise; a failed connect is evicted so the next call retries instead of caching a rejected promise.
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const net = require('node:net');
6
+ const { inspect } = require('node:util');
7
+ const mongodb = require('mongodb');
8
+
9
+ const originalConnect = mongodb.MongoClient.connect;
10
+
11
+ function freshMongoDBOps(connectImpl) {
12
+ delete require.cache[require.resolve('../lib/mongodb-ops')];
13
+ mongodb.MongoClient.connect = connectImpl;
14
+ const MongoDBOps = require('../lib/mongodb-ops');
15
+ MongoDBOps.dbClients = undefined;
16
+ MongoDBOps.dbClientList = undefined;
17
+ MongoDBOps.clientOptions = undefined;
18
+ return MongoDBOps;
19
+ }
20
+
21
+ test.afterEach(() => {
22
+ mongodb.MongoClient.connect = originalConnect;
23
+ delete require.cache[require.resolve('../lib/mongodb-ops')];
24
+ });
25
+
26
+ function privateConnection(hosts, scheme = 'mongodb') {
27
+ const user = 'diaguser';
28
+ const pass = ['not', 'a', 'real', 'pw'].join('-');
29
+ const database = 'diagnostic_private_database';
30
+ const appName = 'diagnostic_private_app';
31
+ const uri = `${scheme}://${user}:${pass}@${hosts}/${database}?appName=${appName}`;
32
+ return { uri, secrets: [user, pass, database, appName, uri] };
33
+ }
34
+
35
+ function captureConsoleError(t, secrets = []) {
36
+ const original = console.error;
37
+ const calls = [];
38
+ console.error = (...args) => calls.push(args);
39
+ t.after(() => {
40
+ console.error = original;
41
+ // Check every argument, including unprefixed lines and object arguments.
42
+ for (const args of calls) {
43
+ for (const arg of args) {
44
+ const text = typeof arg === 'string' ? arg : inspect(arg, { depth: null, customInspect: false });
45
+ for (const secret of secrets) {
46
+ assert.equal(text.includes(secret), false, 'console.error leaked private connection content');
47
+ }
48
+ assert.doesNotMatch(text, /(?:mongodb(?:\+srv)?|https?):\/\//i);
49
+ }
50
+ }
51
+ });
52
+ return calls;
53
+ }
54
+
55
+ async function silentServer() {
56
+ const sockets = new Set();
57
+ const server = net.createServer((socket) => {
58
+ sockets.add(socket);
59
+ socket.on('close', () => sockets.delete(socket));
60
+ });
61
+ await new Promise((resolve, reject) => {
62
+ server.once('error', reject);
63
+ server.listen(0, '127.0.0.1', resolve);
64
+ });
65
+ return {
66
+ address: `127.0.0.1:${server.address().port}`,
67
+ async close() {
68
+ for (const socket of sockets) { socket.destroy(); }
69
+ await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
70
+ }
71
+ };
72
+ }
73
+
74
+ async function rejection(promise) {
75
+ let rejection;
76
+ await assert.rejects(promise, (err) => {
77
+ rejection = err;
78
+ return true;
79
+ });
80
+ return rejection;
81
+ }
82
+
83
+ async function realSelectionError() {
84
+ const server = await silentServer();
85
+ try {
86
+ return await rejection(originalConnect.call(mongodb.MongoClient,
87
+ `mongodb://${server.address}/?directConnection=true`, { serverSelectionTimeoutMS: 300 }));
88
+ } finally {
89
+ await server.close();
90
+ }
91
+ }
92
+
93
+ function topologyError(message = 'selection failed') {
94
+ return new mongodb.MongoServerSelectionError(message, {
95
+ type: 'ReplicaSetNoPrimary',
96
+ setName: 'rs_allowed',
97
+ servers: new Map([['127.0.0.1:27017', { type: 'Unknown', roundTripTime: -1, error: null }]])
98
+ });
99
+ }
100
+
101
+ test('T1: pending host logs once across concurrent callers and the write catch', async (t) => {
102
+ const server = await silentServer();
103
+ const connection = privateConnection(server.address);
104
+ const uri = `${connection.uri}&directConnection=true`;
105
+ const calls = captureConsoleError(t, connection.secrets);
106
+ const MongoDBOps = freshMongoDBOps(originalConnect);
107
+ MongoDBOps.clientOptions = { serverSelectionTimeoutMS: 1000 };
108
+ try {
109
+ const errors = await Promise.all(Array.from({ length: 2 }, () =>
110
+ rejection(MongoDBOps.writeData('insertOne', 'log', { a: 1 }, undefined, uri))));
111
+ assert.ok(errors[0] instanceof Error);
112
+ assert.equal(errors[0].message, 'Server selection timed out after 1000 ms');
113
+ assert.equal(errors[1], errors[0]);
114
+ assert.equal(calls.length, 1);
115
+ assert.equal(calls[0].length, 1);
116
+ assert.match(calls[0][0], /^\[mongodb-ops\] connect MongoServerSelectionError: Server selection timed out after 1000 ms \| topology=/);
117
+ assert.ok(calls[0][0].includes(`${server.address} type=Unknown rtt=-1 error=none`));
118
+ } finally {
119
+ try { await MongoDBOps.closeDBConn(); } finally { await server.close(); }
120
+ }
121
+ });
122
+
123
+ test('T2: refused hosts retain per-host errors and the allowed replica-set name', async (t) => {
124
+ const connection = privateConnection('127.0.0.1:1,127.0.0.1:2');
125
+ const calls = captureConsoleError(t, connection.secrets);
126
+ const MongoDBOps = freshMongoDBOps(originalConnect);
127
+ MongoDBOps.clientOptions = { serverSelectionTimeoutMS: 1000 };
128
+ try {
129
+ const err = await rejection(MongoDBOps.writeData('insertOne', 'log', {}, undefined,
130
+ `${connection.uri}&replicaSet=rs0`));
131
+ assert.ok(err instanceof Error);
132
+ assert.match(err.message, /ECONNREFUSED/);
133
+ assert.equal(calls.length, 1);
134
+ assert.equal(calls[0].length, 1);
135
+ assert.match(calls[0][0], /topology=ReplicaSetNoPrimary setName=rs0 servers=2 \|/);
136
+ for (const port of [1, 2]) {
137
+ assert.ok(calls[0][0].includes(`127.0.0.1:${port} type=Unknown rtt=-1 error=MongoNetworkError: connect ECONNREFUSED 127.0.0.1:${port}`));
138
+ }
139
+ } finally {
140
+ await MongoDBOps.closeDBConn();
141
+ }
142
+ });
143
+
144
+ test('T3: post-connect single and bulk failures log without replacing either Error', async (t) => {
145
+ const connection = privateConnection('post-connect.example.test');
146
+ const calls = captureConsoleError(t, connection.secrets);
147
+ const singleError = await realSelectionError();
148
+ const bulkError = await realSelectionError();
149
+ const MongoDBOps = freshMongoDBOps(async () => ({
150
+ db: () => ({ collection: () => ({
151
+ insertOne: async () => { throw singleError; },
152
+ bulkWrite: async () => { throw bulkError; }
153
+ }) }),
154
+ close: async () => {}
155
+ }));
156
+ try {
157
+ const single = await rejection(MongoDBOps.writeData('insertOne', 'log', {}, undefined, connection.uri));
158
+ const bulk = await rejection(MongoDBOps.writeBulkData('insertBulk', 'log', [{}], false, connection.uri));
159
+ assert.equal(single, singleError);
160
+ assert.equal(bulk, bulkError);
161
+ assert.equal(single.message, 'Server selection timed out after 300 ms');
162
+ assert.equal(bulk.message, 'Server selection timed out after 300 ms');
163
+ assert.equal(calls.length, 2);
164
+ assert.match(calls[0][0], /^\[mongodb-ops\] writeData /);
165
+ assert.match(calls[1][0], /^\[mongodb-ops\] writeBulkData /);
166
+ } finally {
167
+ await MongoDBOps.closeDBConn();
168
+ }
169
+ });
170
+
171
+ test('T4: errors without topology are unchanged and produce no console output', async (t) => {
172
+ const connection = privateConnection('no-topology.example.test');
173
+ const calls = captureConsoleError(t, connection.secrets);
174
+ const suppliedError = new Error('boom');
175
+ const MongoDBOps = freshMongoDBOps(async () => { throw suppliedError; });
176
+ try {
177
+ const err = await rejection(MongoDBOps.writeData('insertOne', 'log', {}, undefined, connection.uri));
178
+ assert.equal(err, suppliedError);
179
+ assert.equal(err.message, 'boom');
180
+ assert.equal(calls.length, 0);
181
+ } finally {
182
+ await MongoDBOps.closeDBConn();
183
+ }
184
+ });
185
+
186
+ test('P1-3: redact URI and userinfo text before truncating or logging any error message', async (t) => {
187
+ const connection = privateConnection('private.example.test', 'mongodb+srv');
188
+ const plainUri = 'mongodb://private.example.test/private_database?appName=private_query';
189
+ const httpUri = 'https://private.example.test/private_path';
190
+ const userinfo = `${connection.secrets[0]}:${connection.secrets[1]}@private.example.test`;
191
+ const calls = captureConsoleError(t, [...connection.secrets, 'private_database', 'private_query', 'private_path']);
192
+ const suppliedError = topologyError(`failed ${connection.uri}\nretry ${plainUri} via ${httpUri} userinfo ${userinfo}`);
193
+ suppliedError.reason.servers.get('127.0.0.1:27017').error = new mongodb.MongoNetworkError(
194
+ `host failure ${connection.uri}\nretry ${plainUri} userinfo ${userinfo}`);
195
+ suppliedError.reason.servers.set('127.0.0.1:27018', {
196
+ type: 'Unknown', roundTripTime: -1,
197
+ error: new mongodb.MongoNetworkError(`host failure ${'x'.repeat(160)} ${connection.uri}`)
198
+ });
199
+ const originalMessage = suppliedError.message;
200
+ const originalHostMessage = suppliedError.reason.servers.get('127.0.0.1:27017').error.message;
201
+ const MongoDBOps = freshMongoDBOps(async () => { throw suppliedError; });
202
+ try {
203
+ assert.equal(await rejection(MongoDBOps.getDbClient(connection.uri)), suppliedError);
204
+ assert.equal(calls.length, 1);
205
+ assert.match(calls[0][0], /\[REDACTED_URI\]/);
206
+ assert.match(calls[0][0], /\[REDACTED_USERINFO\]/);
207
+ assert.match(calls[0][0], /setName=rs_allowed/);
208
+ assert.match(calls[0][0], /127\.0\.0\.1:27017 type=Unknown rtt=-1/);
209
+ assert.doesNotMatch(calls[0][0], /[\r\n]/);
210
+ assert.equal(suppliedError.message, originalMessage);
211
+ assert.equal(suppliedError.reason.servers.get('127.0.0.1:27017').error.message, originalHostMessage);
212
+ } finally {
213
+ await MongoDBOps.closeDBConn();
214
+ }
215
+ });
216
+
217
+ for (const failure of ['console.error', 'message getter', 'host message getter', 'servers getter']) {
218
+ test(`P1-2: throwing ${failure} preserves the connect Error and permits reconnection`, async (t) => {
219
+ const connection = privateConnection('guard.example.test');
220
+ const calls = captureConsoleError(t, connection.secrets);
221
+ const suppliedError = topologyError();
222
+ const loggerError = new Error('diagnostics failed');
223
+ if (failure === 'message getter') {
224
+ Object.defineProperty(suppliedError, 'message', { get() { throw loggerError; } });
225
+ } else if (failure === 'host message getter') {
226
+ const hostError = new Error('host failed');
227
+ Object.defineProperty(hostError, 'message', { get() { throw loggerError; } });
228
+ suppliedError.reason.servers.get('127.0.0.1:27017').error = hostError;
229
+ } else if (failure === 'servers getter') {
230
+ Object.defineProperty(suppliedError.reason, 'servers', { get() { throw loggerError; } });
231
+ }
232
+ const client = { close: async () => {} };
233
+ let attempts = 0;
234
+ const MongoDBOps = freshMongoDBOps(async () => {
235
+ if (++attempts === 1) { throw suppliedError; }
236
+ return client;
237
+ });
238
+ const cachedAtLog = [];
239
+ if (failure === 'console.error') {
240
+ console.error = (...args) => {
241
+ calls.push(args);
242
+ cachedAtLog.push(MongoDBOps.dbClients.size);
243
+ throw loggerError;
244
+ };
245
+ }
246
+ try {
247
+ const err = await rejection(MongoDBOps.getDbClient(connection.uri));
248
+ assert.ok(err === suppliedError, 'diagnostics must preserve the original Error by identity');
249
+ assert.equal(MongoDBOps.dbClients.size, 0);
250
+ assert.ok(cachedAtLog.every((size) => size === 0), 'evict the failed connection before logging');
251
+ assert.equal(await MongoDBOps.getDbClient(connection.uri), client);
252
+ assert.equal(attempts, 2);
253
+ } finally {
254
+ await MongoDBOps.closeDBConn();
255
+ }
256
+ });
257
+ }
258
+
259
+ test('P1-2: a throwing log sink cannot replace post-connect single or bulk Errors', async (t) => {
260
+ const connection = privateConnection('write-guard.example.test');
261
+ const calls = captureConsoleError(t, connection.secrets);
262
+ console.error = (...args) => { calls.push(args); throw new Error('log sink failed'); };
263
+ const singleError = topologyError('single failed');
264
+ const bulkError = topologyError('bulk failed');
265
+ const MongoDBOps = freshMongoDBOps(async () => ({
266
+ db: () => ({ collection: () => ({
267
+ insertOne: async () => { throw singleError; },
268
+ bulkWrite: async () => { throw bulkError; }
269
+ }) }),
270
+ close: async () => {}
271
+ }));
272
+ try {
273
+ assert.equal(await rejection(MongoDBOps.writeData('insertOne', 'log', {}, undefined, connection.uri)), singleError);
274
+ assert.equal(await rejection(MongoDBOps.writeBulkData('insertBulk', 'log', [{}], false, connection.uri)), bulkError);
275
+ } finally {
276
+ await MongoDBOps.closeDBConn();
277
+ }
278
+ });