core-services-sdk 1.3.25 → 1.3.27

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.25",
3
+ "version": "1.3.27",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -35,6 +35,7 @@
35
35
  "http-status": "^2.1.0",
36
36
  "mongodb": "^6.18.0",
37
37
  "nodemailer": "^7.0.5",
38
+ "p-retry": "^7.0.0",
38
39
  "pino": "^9.7.0",
39
40
  "ulid": "^3.0.1",
40
41
  "uuid": "^11.1.0",
@@ -1,23 +1,56 @@
1
+ import pRetry from 'p-retry'
1
2
  import { MongoClient, ServerApiVersion } from 'mongodb'
2
3
 
3
4
  /**
4
- * Connects to MongoDB.
5
+ * Connects to MongoDB with retry and timeout support.
5
6
  *
6
7
  * @param {Object} options
7
8
  * @param {string} options.uri - MongoDB connection URI.
8
9
  * @param {object} [options.serverApi] - Optional serverApi configuration.
9
- * @returns {Promise<import('mongodb').MongoClient>}
10
+ * @param {number} [options.timeout=5000] - Timeout in ms for each attempt.
11
+ * @param {number} [options.retries=3] - Number of retry attempts.
12
+ * @returns {Promise<MongoClient>}
10
13
  */
14
+ export const mongoConnect = async ({
15
+ uri,
16
+ serverApi,
17
+ timeout = 5000,
18
+ retries = 3,
19
+ }) => {
20
+ const attemptConnect = async () => {
21
+ const connectPromise = MongoClient.connect(uri, {
22
+ serverApi: {
23
+ version: ServerApiVersion.v1,
24
+ strict: true,
25
+ deprecationErrors: true,
26
+ ...serverApi,
27
+ },
28
+ connectTimeoutMS: timeout,
29
+ socketTimeoutMS: timeout,
30
+ })
11
31
 
12
- export const mongoConnect = async ({ uri, serverApi }) => {
13
- const client = await MongoClient.connect(uri, {
14
- serverApi: {
15
- version: ServerApiVersion.v1,
16
- strict: true,
17
- deprecationErrors: true,
18
- ...serverApi,
32
+ // Race connection vs timeout
33
+ const timeoutPromise = new Promise((_, reject) =>
34
+ setTimeout(
35
+ () =>
36
+ reject(new Error(`MongoDB connection timed out after ${timeout}ms`)),
37
+ timeout,
38
+ ),
39
+ )
40
+
41
+ return Promise.race([connectPromise, timeoutPromise])
42
+ }
43
+
44
+ return pRetry(attemptConnect, {
45
+ retries,
46
+ factor: 2, // exponential backoff multiplier
47
+ minTimeout: 500, // wait 500ms before first retry
48
+ maxTimeout: timeout, // cap backoff at timeout value
49
+ onFailedAttempt: (error) => {
50
+ console.warn(
51
+ `MongoDB connection attempt ${error.attemptNumber} failed. ` +
52
+ `There are ${error.retriesLeft} retries left. Reason: ${error.message}`,
53
+ )
19
54
  },
20
55
  })
21
-
22
- return client
23
56
  }
@@ -1,4 +1,5 @@
1
1
  export * from './connect.js'
2
+ export * from './paginate.js'
2
3
  export * from './dsl-to-mongo.js'
3
4
  export * from './initialize-mongodb.js'
4
5
  export * from './validate-mongo-uri.js'
@@ -0,0 +1,48 @@
1
+ import { ObjectId } from 'mongodb'
2
+
3
+ /**
4
+ * Pagination with SQL-like ascending/descending
5
+ *
6
+ * @param {import('mongodb').Collection} collection
7
+ * @param {Object} options
8
+ * @param {Object} [options.filter={}]
9
+ * @param {string} [options.cursorField='_id']
10
+ * @param {string|Date|ObjectId} [options.cursor]
11
+ * @param {'asc'|'desc'} [options.order='asc']
12
+ * @param {number} [options.limit=10]
13
+ */
14
+ export async function paginate(
15
+ collection,
16
+ {
17
+ limit = 10,
18
+ projection,
19
+ filter = {},
20
+ cursor = null,
21
+ order = 'desc',
22
+ cursorField = '_id',
23
+ } = {},
24
+ ) {
25
+ const query = { ...filter }
26
+ const sort = { [cursorField]: order === 'asc' ? 1 : -1 }
27
+
28
+ if (cursor) {
29
+ if (cursorField === '_id') {
30
+ cursor = new ObjectId(cursor)
31
+ }
32
+
33
+ query[cursorField] = order === 'asc' ? { $gt: cursor } : { $lt: cursor }
34
+ }
35
+
36
+ const results = await collection
37
+ .find(query, { projection })
38
+ .sort(sort)
39
+ .limit(limit)
40
+ .toArray()
41
+
42
+ return {
43
+ order,
44
+ list: results,
45
+ previous: results.length ? results[0][cursorField] : null,
46
+ next: results.length ? results[results.length - 1][cursorField] : null,
47
+ }
48
+ }
@@ -0,0 +1,62 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
+ import { mongoConnect } from '../../src/mongodb/connect.js'
4
+ import { startMongo, stopMongo } from '../resources/docker-mongo-test.js'
5
+
6
+ const MONGO_PORT = 29060
7
+ const CONTAINER_NAME = 'mongo-connect-integration-test'
8
+
9
+ describe('mongoConnect - Integration', () => {
10
+ let client
11
+
12
+ beforeAll(async () => {
13
+ // Start a fresh Mongo container
14
+ startMongo(MONGO_PORT, CONTAINER_NAME)
15
+ })
16
+
17
+ afterAll(async () => {
18
+ if (client) {
19
+ await client.close()
20
+ }
21
+ stopMongo(CONTAINER_NAME)
22
+ })
23
+
24
+ it('successfully connects to a running MongoDB container', async () => {
25
+ client = await mongoConnect({
26
+ uri: `mongodb://0.0.0.0:${MONGO_PORT}`,
27
+ })
28
+
29
+ const db = client.db('integration-test')
30
+ const col = db.collection('docs')
31
+
32
+ await col.insertOne({ name: 'hello' })
33
+ const found = await col.findOne({ name: 'hello' })
34
+
35
+ expect(found).toMatchObject({ name: 'hello' })
36
+ })
37
+
38
+ it('respects custom timeout', async () => {
39
+ // should connect quickly
40
+ client = await mongoConnect({
41
+ uri: `mongodb://0.0.0.0:${MONGO_PORT}`,
42
+ timeout: 2000,
43
+ })
44
+
45
+ expect(client).toBeDefined()
46
+ })
47
+
48
+ it('fails to connect to an invalid port with retries', async () => {
49
+ const badPort = 29999 // unused port
50
+
51
+ await expect(
52
+ mongoConnect({
53
+ uri: `mongodb://0.0.0.0:${badPort}`,
54
+ timeout: 1000,
55
+ retries: 2,
56
+ }),
57
+ ).rejects.toThrow()
58
+
59
+ // should try initial + retries
60
+ // we don’t assert number of attempts here because it’s handled by p-retry
61
+ })
62
+ })
@@ -23,7 +23,7 @@ describe('mongoConnect', () => {
23
23
  MongoClient.connect.mockResolvedValue(fakeClient)
24
24
  })
25
25
 
26
- it('should call MongoClient.connect with default serverApi options', async () => {
26
+ it('should call MongoClient.connect with default serverApi options and default timeouts', async () => {
27
27
  const uri = 'mongodb://localhost:27017'
28
28
  await mongoConnect({ uri })
29
29
 
@@ -33,6 +33,8 @@ describe('mongoConnect', () => {
33
33
  strict: true,
34
34
  deprecationErrors: true,
35
35
  },
36
+ connectTimeoutMS: 5000,
37
+ socketTimeoutMS: 5000,
36
38
  })
37
39
  })
38
40
 
@@ -48,13 +50,53 @@ describe('mongoConnect', () => {
48
50
  strict: false,
49
51
  deprecationErrors: true,
50
52
  },
53
+ connectTimeoutMS: 5000,
54
+ socketTimeoutMS: 5000,
51
55
  })
52
56
  })
53
57
 
58
+ it('should honor custom timeout value', async () => {
59
+ const uri = 'mongodb://localhost:27017'
60
+ await mongoConnect({ uri, timeout: 2000 })
61
+
62
+ expect(MongoClient.connect).toHaveBeenCalledWith(
63
+ uri,
64
+ expect.objectContaining({
65
+ connectTimeoutMS: 2000,
66
+ socketTimeoutMS: 2000,
67
+ }),
68
+ )
69
+ })
70
+
54
71
  it('should return the connected client', async () => {
55
72
  const uri = 'mongodb://localhost:27017'
56
73
  const client = await mongoConnect({ uri })
57
74
 
58
75
  expect(client).toBe(fakeClient)
59
76
  })
77
+
78
+ it('should retry when connection fails initially', async () => {
79
+ const uri = 'mongodb://localhost:27017'
80
+
81
+ // fail first attempt, succeed second
82
+ MongoClient.connect
83
+ .mockRejectedValueOnce(new Error('temporary error'))
84
+ .mockResolvedValueOnce(fakeClient)
85
+
86
+ const client = await mongoConnect({ uri, retries: 2 })
87
+
88
+ expect(client).toBe(fakeClient)
89
+ expect(MongoClient.connect).toHaveBeenCalledTimes(2)
90
+ })
91
+
92
+ it('should throw after exceeding retries', async () => {
93
+ const uri = 'mongodb://localhost:27017'
94
+ MongoClient.connect.mockRejectedValue(new Error('always fails'))
95
+
96
+ await expect(
97
+ mongoConnect({ uri, retries: 2, timeout: 100 }),
98
+ ).rejects.toThrow(/always fails/)
99
+
100
+ expect(MongoClient.connect).toHaveBeenCalledTimes(3) // initial + 2 retries
101
+ })
60
102
  })
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
2
+
3
+ import { paginate } from '../../src/mongodb/paginate.js'
4
+ import { startMongo, stopMongo } from '../resources/docker-mongo-test.js'
5
+ import { initializeMongoDb } from '../../src/mongodb/initialize-mongodb.js'
6
+
7
+ const MONGO_PORT = 29050
8
+ const CONTAINER_NAME = 'mongo-auth-attempts-paginate-test'
9
+
10
+ let db
11
+ let collection
12
+ describe('paginate - Integration', () => {
13
+ beforeAll(async () => {
14
+ startMongo(MONGO_PORT, CONTAINER_NAME)
15
+
16
+ db = await initializeMongoDb({
17
+ config: {
18
+ uri: `mongodb://0.0.0.0:${MONGO_PORT}`,
19
+ options: { dbName: 'users-management' },
20
+ },
21
+ collectionNames: {
22
+ TestDocs: 'test_docs',
23
+ },
24
+ })
25
+
26
+ collection = db.TestDocs
27
+ })
28
+ afterAll(() => {
29
+ stopMongo(CONTAINER_NAME)
30
+ })
31
+
32
+ beforeEach(async () => {
33
+ await collection.deleteMany({})
34
+ })
35
+
36
+ const seedDocs = async (count = 12) => {
37
+ const docs = Array.from({ length: count }).map((_, i) => ({
38
+ name: `doc_${i}`,
39
+ createdAt: new Date(Date.now() + i * 1000),
40
+ }))
41
+ await collection.insertMany(docs)
42
+ return docs
43
+ }
44
+
45
+ it('returns first page with limit', async () => {
46
+ await seedDocs(12)
47
+
48
+ const result = await paginate(collection, {
49
+ filter: {},
50
+ limit: 5,
51
+ order: 'desc',
52
+ cursorField: 'createdAt',
53
+ })
54
+
55
+ expect(result.list).toHaveLength(5)
56
+ expect(result.next).toBeDefined()
57
+ expect(result.previous).toBeDefined()
58
+ })
59
+
60
+ it('returns empty when no docs match filter', async () => {
61
+ const result = await paginate(collection, {
62
+ filter: { name: 'not-exist' },
63
+ limit: 5,
64
+ order: 'desc',
65
+ })
66
+
67
+ expect(result.list).toHaveLength(0)
68
+ expect(result.next).toBeNull()
69
+ })
70
+
71
+ it('supports ascending order', async () => {
72
+ const docs = await seedDocs(3)
73
+
74
+ const ascResult = await paginate(collection, {
75
+ filter: {},
76
+ limit: 3,
77
+ order: 'asc',
78
+ cursorField: 'createdAt',
79
+ })
80
+
81
+ expect(ascResult.list[0].name).toBe(docs[0].name)
82
+ })
83
+
84
+ it('supports descending order', async () => {
85
+ const docs = await seedDocs(3)
86
+
87
+ const descResult = await paginate(collection, {
88
+ filter: {},
89
+ limit: 3,
90
+ order: 'desc',
91
+ cursorField: 'createdAt',
92
+ })
93
+
94
+ expect(descResult.list[0].name).toBe(docs[2].name)
95
+ })
96
+
97
+ it('paginates with cursor (next page)', async () => {
98
+ await seedDocs(7)
99
+
100
+ // first page
101
+ const firstPage = await paginate(collection, {
102
+ filter: {},
103
+ limit: 3,
104
+ order: 'asc',
105
+ cursorField: 'createdAt',
106
+ })
107
+
108
+ expect(firstPage.list).toHaveLength(3)
109
+ expect(firstPage.next).toBeDefined()
110
+
111
+ // second page using cursor
112
+ const secondPage = await paginate(collection, {
113
+ filter: {},
114
+ limit: 3,
115
+ order: 'asc',
116
+ cursorField: 'createdAt',
117
+ cursor: firstPage.next,
118
+ })
119
+
120
+ expect(secondPage.list).toHaveLength(3)
121
+ // validate no overlap between first and second page
122
+ expect(secondPage.list[0]._id.toString()).not.toBe(
123
+ firstPage.list[0]._id.toString(),
124
+ )
125
+ expect(secondPage.list.map((d) => d._id.toString())).not.toEqual(
126
+ firstPage.list.map((d) => d._id.toString()),
127
+ )
128
+ })
129
+
130
+ it('paginates with stringified ObjectId as cursor', async () => {
131
+ const docs = await seedDocs(5)
132
+
133
+ // @ts-ignore
134
+ const stringCursor = docs[2]._id.toString()
135
+
136
+ const page = await paginate(collection, {
137
+ filter: {},
138
+ limit: 2,
139
+ order: 'asc',
140
+ cursorField: '_id',
141
+ cursor: stringCursor,
142
+ })
143
+
144
+ expect(page.list).toHaveLength(2)
145
+
146
+ // @ts-ignore
147
+ expect(page.list[0]._id.toString()).toBe(docs[3]._id.toString())
148
+ // @ts-ignore
149
+ expect(page.list[1]._id.toString()).toBe(docs[4]._id.toString())
150
+ })
151
+ })
@@ -0,0 +1,103 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ /**
4
+ * Start a MongoDB Docker container for testing (standalone)
5
+ * @param {number} port - Host port for MongoDB
6
+ * @param {string} containerName - Name of the Docker container
7
+ */
8
+ export function startMongo(port = 27027, containerName = 'mongo-test') {
9
+ console.log(`[MongoTest] Starting MongoDB on port ${port}...`)
10
+
11
+ stopMongo(containerName)
12
+
13
+ // Start MongoDB detached
14
+ execSync(`docker run -d --name ${containerName} -p ${port}:27017 mongo:6.0`, {
15
+ stdio: 'inherit',
16
+ })
17
+
18
+ waitForMongo(port)
19
+ }
20
+
21
+ /**
22
+ * Start a MongoDB Replica Set Docker container for testing
23
+ * @param {number} port - Host port for MongoDB
24
+ * @param {string} containerName - Name of the Docker container
25
+ * @param {string} replSet - Replica set name
26
+ */
27
+ export function startMongoReplicaSet(
28
+ port = 27027,
29
+ containerName = 'mongo-rs-test',
30
+ replSet = 'rs0',
31
+ ) {
32
+ console.log(
33
+ `[MongoTest] Starting MongoDB Replica Set "${replSet}" on port ${port}...`,
34
+ )
35
+
36
+ try {
37
+ // Run mongo with replica set enabled
38
+ execSync(
39
+ `docker run -d --name ${containerName} -p ${port}:27017 mongo:6.0 mongod --replSet ${replSet} --bind_ip_all`,
40
+ { stdio: 'inherit' },
41
+ )
42
+
43
+ waitForMongo(port)
44
+
45
+ // Initialize replica set
46
+ console.log(`[MongoTest] Initializing replica set "${replSet}"...`)
47
+ execSync(
48
+ `docker exec ${containerName} mongosh --eval "rs.initiate({_id: '${replSet}', members:[{ _id:0, host: 'localhost:${port}' }]})"`,
49
+ { stdio: 'inherit' },
50
+ )
51
+ } catch {
52
+ console.warn(`[MongoTest] Replica set may already be initiated.`)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Stop and remove the MongoDB Docker container
58
+ * @param {string} containerName
59
+ */
60
+ export function stopMongo(containerName = 'mongo-test') {
61
+ console.log(`[MongoTest] Stopping MongoDB...`)
62
+ try {
63
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
64
+ } catch {}
65
+ }
66
+
67
+ function isConnected(port) {
68
+ try {
69
+ execSync(`mongosh --port ${port} --eval "db.runCommand({ ping: 1 })"`, {
70
+ stdio: 'ignore',
71
+ })
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+ /**
78
+ * Wait until MongoDB is ready to accept connections
79
+ * @param {number} port
80
+ */
81
+ function waitForMongo(port) {
82
+ console.log(`[MongoTest] Waiting for MongoDB to be ready...`)
83
+ const maxRetries = 20
84
+ let retries = 0
85
+ let connected = false
86
+
87
+ while (!connected && retries < maxRetries) {
88
+ try {
89
+ connected = isConnected(port)
90
+ retries++
91
+ } catch {
92
+ retries++
93
+ execSync(`sleep 1`)
94
+ }
95
+ }
96
+
97
+ if (!connected) {
98
+ throw new Error(
99
+ `[MongoTest] MongoDB failed to start within ${maxRetries} seconds`,
100
+ )
101
+ }
102
+ console.log(`[MongoTest] MongoDB is ready.`)
103
+ }
@@ -18,6 +18,7 @@ export function buildServer() {
18
18
 
19
19
  // POST /search with body { where: {...} }
20
20
  app.post('/search', async (req) => {
21
+ // @ts-ignore
21
22
  const dsl = normalizeOperators(req.body?.where || {})
22
23
  const mongo = toMongo(dsl)
23
24
  return { dsl, mongo }