core-services-sdk 1.3.57 → 1.3.60
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/.claude/settings.local.json +22 -0
- package/.prettierrc.json +0 -1
- package/package.json +4 -2
- package/src/core/index.js +1 -0
- package/src/core/parse-json-if-string.js +19 -0
- package/src/mongodb/index.js +1 -0
- package/src/postgresql/index.js +1 -0
- package/src/postgresql/paginate.js +61 -0
- package/src/postgresql/start-stop-postgres-docker.js +1 -1
- package/src/rabbit-mq/index.js +1 -0
- package/src/rabbit-mq/rabbit-mq.js +114 -21
- package/src/rabbit-mq/start-stop-rabbitmq.js +152 -0
- package/tests/core/parse-json-if-string.unit.test.js +51 -0
- package/tests/postgresql/paginate.integration.test.js +166 -0
- package/tests/rabbit-mq/rabbit-mq.test.js +121 -51
- package/tests/resources/docker-mongo-test.js +3 -5
- package/types/mongodb/index.d.ts +1 -0
- package/types/postgresql/index.d.ts +1 -0
- package/types/postgresql/paginate.d.ts +26 -0
- package/types/rabbit-mq/index.d.ts +1 -0
- package/types/rabbit-mq/rabbit-mq.d.ts +1 -1
- package/types/rabbit-mq/start-stop-rabbitmq.d.ts +74 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(npm test:*)",
|
|
5
|
+
"Bash(docker rm:*)",
|
|
6
|
+
"Bash(docker run:*)",
|
|
7
|
+
"Bash(mongosh:*)",
|
|
8
|
+
"Bash(for:*)",
|
|
9
|
+
"Bash(do if mongosh --port 27099 --eval \"db.runCommand({ ping: 1 })\")",
|
|
10
|
+
"Bash(then echo \"Connected after $i attempts\")",
|
|
11
|
+
"Bash(break)",
|
|
12
|
+
"Bash(fi)",
|
|
13
|
+
"Bash(echo:*)",
|
|
14
|
+
"Bash(done)",
|
|
15
|
+
"Bash(docker logs:*)",
|
|
16
|
+
"Bash(docker system:*)",
|
|
17
|
+
"Bash(docker volume prune:*)",
|
|
18
|
+
"Bash(docker image prune:*)",
|
|
19
|
+
"Bash(docker builder prune:*)"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
package/.prettierrc.json
CHANGED
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "core-services-sdk",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.60",
|
|
4
4
|
"main": "src/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"lint": "eslint .",
|
|
9
9
|
"lint:fix": "eslint . --fix",
|
|
10
|
-
"test": "vitest run --coverage",
|
|
11
10
|
"format": "prettier --write .",
|
|
11
|
+
"test": "vitest run --coverage",
|
|
12
|
+
"check-format": "prettier --check .",
|
|
12
13
|
"bump": "node ./scripts/bump-version.js",
|
|
14
|
+
"check": "npm run lint && npm run check-format && npm run test",
|
|
13
15
|
"build:types": "tsc --project tsconfig.json && prettier --write ."
|
|
14
16
|
},
|
|
15
17
|
"repository": {
|
package/src/core/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export * from './otp-generators.js'
|
|
|
4
4
|
export * from './sanitize-objects.js'
|
|
5
5
|
export * from './normalize-min-max.js'
|
|
6
6
|
export * from './normalize-to-array.js'
|
|
7
|
+
export * from './parse-json-if-string.js'
|
|
7
8
|
export * from './combine-unique-arrays.js'
|
|
8
9
|
export * from './normalize-phone-number.js'
|
|
9
10
|
export * from './normalize-array-operators.js'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a value that may be a JSON string or already a parsed object.
|
|
3
|
+
*
|
|
4
|
+
* @param {any} value
|
|
5
|
+
* The value to parse.
|
|
6
|
+
*
|
|
7
|
+
* @returns {any}
|
|
8
|
+
* Parsed JSON if value is a string, otherwise the original value.
|
|
9
|
+
*
|
|
10
|
+
* @throws {Error}
|
|
11
|
+
* Throws if the string is not valid JSON.
|
|
12
|
+
*/
|
|
13
|
+
export const parseJsonIfString = (value) => {
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
return JSON.parse(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return value
|
|
19
|
+
}
|
package/src/mongodb/index.js
CHANGED
package/src/postgresql/index.js
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { toSnakeCase } from '../core/case-mapper.js'
|
|
2
|
+
import { normalizeNumberOrDefault } from '../core/normalize-premitives-types-or-default.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic pagination utility.
|
|
6
|
+
*
|
|
7
|
+
* @async
|
|
8
|
+
* @param {Object} params
|
|
9
|
+
* @param {Object} params.db
|
|
10
|
+
* @param {string} params.tableName
|
|
11
|
+
* @param {number} [params.page=1]
|
|
12
|
+
* @param {number} [params.limit=10]
|
|
13
|
+
* @param {Object} [params.filter={}]
|
|
14
|
+
* @param {Object} [params.orderBy]
|
|
15
|
+
* @param {string} params.orderBy.column
|
|
16
|
+
* @param {'asc'|'desc'} [params.orderBy.direction='asc']
|
|
17
|
+
* @returns {Promise<{
|
|
18
|
+
* list: any[],
|
|
19
|
+
* totalCount: number,
|
|
20
|
+
* totalPages: number,
|
|
21
|
+
* currentPage: number,
|
|
22
|
+
* hasNext: boolean,
|
|
23
|
+
* hasPrevious: boolean
|
|
24
|
+
* }>}
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const sqlPaginate = async ({
|
|
28
|
+
db,
|
|
29
|
+
mapRow,
|
|
30
|
+
orderBy,
|
|
31
|
+
page = 1,
|
|
32
|
+
limit = 10,
|
|
33
|
+
tableName,
|
|
34
|
+
filter = {},
|
|
35
|
+
} = {}) => {
|
|
36
|
+
const offset = (page - 1) * limit
|
|
37
|
+
|
|
38
|
+
const query = orderBy?.column
|
|
39
|
+
? db(tableName)
|
|
40
|
+
.select('*')
|
|
41
|
+
.where(toSnakeCase(filter))
|
|
42
|
+
.orderBy(orderBy.column, orderBy.direction || 'asc')
|
|
43
|
+
: db(tableName).select('*').where(toSnakeCase(filter))
|
|
44
|
+
|
|
45
|
+
const [rows, countResult] = await Promise.all([
|
|
46
|
+
query.limit(limit).offset(offset),
|
|
47
|
+
db(tableName).where(toSnakeCase(filter)).count('* as count').first(),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
|
|
51
|
+
const totalPages = Math.ceil(totalCount / limit)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
list: mapRow ? rows.map(mapRow) : rows,
|
|
55
|
+
totalCount,
|
|
56
|
+
totalPages,
|
|
57
|
+
currentPage: page,
|
|
58
|
+
hasPrevious: page > 1,
|
|
59
|
+
hasNext: page < totalPages,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -124,7 +124,7 @@ export function isPostgresReady(containerName, user, db) {
|
|
|
124
124
|
export function waitForPostgres(containerName, user, db) {
|
|
125
125
|
console.log(`[PgTest] Waiting for PostgreSQL to be ready...`)
|
|
126
126
|
|
|
127
|
-
const maxRetries =
|
|
127
|
+
const maxRetries = 60
|
|
128
128
|
let retries = 0
|
|
129
129
|
let ready = false
|
|
130
130
|
|
package/src/rabbit-mq/index.js
CHANGED
|
@@ -89,17 +89,97 @@ const parseMessage = (msgInfo) => {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* Creates an unsubscribe function for a RabbitMQ consumer.
|
|
93
|
+
*
|
|
94
|
+
* This is a higher-order function that captures the RabbitMQ channel,
|
|
95
|
+
* consumerTag, and logger in a closure, and returns an async function
|
|
96
|
+
* that cancels the consumer when invoked.
|
|
97
|
+
*
|
|
98
|
+
* The returned function is idempotent and safe to call multiple times.
|
|
99
|
+
* If the channel or consumer is already closed, the call is silently ignored.
|
|
100
|
+
*
|
|
101
|
+
* Usage:
|
|
102
|
+
* const unsubscribe = createUnsubscribe({ channel, consumerTag, logger })
|
|
103
|
+
* await unsubscribe()
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} params
|
|
106
|
+
* @param {import('amqplib').Channel} params.channel
|
|
107
|
+
* The RabbitMQ channel on which the consumer was created.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} params.consumerTag
|
|
110
|
+
* The consumer tag returned by `channel.consume`, uniquely identifying
|
|
111
|
+
* the active consumer within the channel.
|
|
112
|
+
*
|
|
113
|
+
* @param {import('pino').Logger} params.logger
|
|
114
|
+
* Base logger instance used to create a child logger for unsubscribe logs.
|
|
115
|
+
*
|
|
116
|
+
* @returns {() => Promise<void>}
|
|
117
|
+
* An async function that cancels this specific RabbitMQ consumer.
|
|
118
|
+
*
|
|
119
|
+
* @throws {Error}
|
|
120
|
+
* Throws for unexpected errors during consumer cancellation.
|
|
121
|
+
* Errors caused by an already closed channel are silently ignored.
|
|
122
|
+
*/
|
|
123
|
+
const unsubscribe =
|
|
124
|
+
({ channel, consumerTag, logger }) =>
|
|
125
|
+
async () => {
|
|
126
|
+
if (!channel || channel.closed) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const t0 = Date.now()
|
|
131
|
+
const child = logger.child({ op: 'unsubscribe', consumerTag })
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
child.debug('start')
|
|
135
|
+
await channel.cancel(consumerTag)
|
|
136
|
+
child.info({ event: 'ok', ms: Date.now() - t0 })
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err?.message?.includes('Channel closed')) {
|
|
139
|
+
child.debug({
|
|
140
|
+
event: 'already-closed',
|
|
141
|
+
reason: 'channel-already-closed',
|
|
142
|
+
})
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
child.error(err, {
|
|
147
|
+
event: 'error',
|
|
148
|
+
ms: Date.now() - t0,
|
|
149
|
+
})
|
|
150
|
+
throw err
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Subscribes to a RabbitMQ queue and returns an unsubscribe function
|
|
156
|
+
* that cancels this specific consumer.
|
|
157
|
+
*
|
|
158
|
+
* Each call creates an independent consumer with its own consumerTag.
|
|
159
|
+
* Calling the returned unsubscribe function affects only this consumer
|
|
160
|
+
* and does not impact other consumers or services.
|
|
93
161
|
*
|
|
94
162
|
* @param {Object} options
|
|
95
|
-
* @param {import('amqplib').Channel} options.channel
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* @param {
|
|
99
|
-
*
|
|
100
|
-
*
|
|
163
|
+
* @param {import('amqplib').Channel} options.channel
|
|
164
|
+
* RabbitMQ channel used to create the consumer.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} options.queue
|
|
167
|
+
* Queue name to subscribe to.
|
|
168
|
+
*
|
|
169
|
+
* @param {(data: any, correlationId?: string) => Promise<void>} options.onReceive
|
|
170
|
+
* Async handler invoked for each received message payload.
|
|
171
|
+
*
|
|
172
|
+
* @param {import('pino').Logger} options.log
|
|
173
|
+
* Base logger instance.
|
|
101
174
|
*
|
|
102
|
-
* @
|
|
175
|
+
* @param {boolean} [options.nackOnError=false]
|
|
176
|
+
* Whether to nack the message on handler error instead of ack.
|
|
177
|
+
*
|
|
178
|
+
* @param {number} [options.prefetch=1]
|
|
179
|
+
* Maximum number of unacknowledged messages for this consumer.
|
|
180
|
+
*
|
|
181
|
+
* @returns {Promise<() => Promise<void>>}
|
|
182
|
+
* Resolves to an unsubscribe function that cancels this specific consumer.
|
|
103
183
|
*/
|
|
104
184
|
export const subscribeToQueue = async ({
|
|
105
185
|
log,
|
|
@@ -111,16 +191,31 @@ export const subscribeToQueue = async ({
|
|
|
111
191
|
}) => {
|
|
112
192
|
const logger = log.child({ op: 'subscribeToQueue', queue })
|
|
113
193
|
|
|
194
|
+
if (!queue || !queue.trim()) {
|
|
195
|
+
const message = 'Cannot subscribe to RabbitMQ with an empty queue name'
|
|
196
|
+
logger.error({ error: message })
|
|
197
|
+
throw new Error(message)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof onReceive !== 'function') {
|
|
201
|
+
const message = `Cannot subscribe to queue "${queue}" because onReceive is not a function`
|
|
202
|
+
logger.error({ error: message })
|
|
203
|
+
throw new Error(message)
|
|
204
|
+
}
|
|
205
|
+
|
|
114
206
|
try {
|
|
115
207
|
await channel.assertQueue(queue, { durable: true })
|
|
116
|
-
|
|
208
|
+
|
|
209
|
+
if (prefetch) {
|
|
210
|
+
await channel.prefetch(prefetch)
|
|
211
|
+
}
|
|
117
212
|
|
|
118
213
|
const { consumerTag } = await channel.consume(queue, async (msgInfo) => {
|
|
119
214
|
if (!msgInfo) {
|
|
120
215
|
return
|
|
121
216
|
}
|
|
122
|
-
const t0 = Date.now()
|
|
123
217
|
|
|
218
|
+
const t0 = Date.now()
|
|
124
219
|
const { msgId, data, correlationId } = parseMessage(msgInfo)
|
|
125
220
|
const child = logger.child({ msgId, correlationId })
|
|
126
221
|
|
|
@@ -131,28 +226,26 @@ export const subscribeToQueue = async ({
|
|
|
131
226
|
await onReceive(data, correlationId)
|
|
132
227
|
channel.ack(msgInfo)
|
|
133
228
|
|
|
134
|
-
child.info({
|
|
135
|
-
event: 'ok',
|
|
136
|
-
ms: Date.now() - t0,
|
|
137
|
-
})
|
|
138
|
-
return
|
|
229
|
+
child.info({ event: 'ok', ms: Date.now() - t0 })
|
|
139
230
|
} catch (err) {
|
|
140
|
-
|
|
231
|
+
if (nackOnError) {
|
|
232
|
+
channel.nack(msgInfo)
|
|
233
|
+
} else {
|
|
234
|
+
channel.ack(msgInfo)
|
|
235
|
+
}
|
|
141
236
|
|
|
142
237
|
child.error(err, {
|
|
143
238
|
event: 'error',
|
|
144
239
|
ms: Date.now() - t0,
|
|
145
240
|
})
|
|
146
|
-
return
|
|
147
241
|
}
|
|
148
242
|
})
|
|
149
243
|
|
|
150
244
|
logger.debug({ consumerTag }, 'consumer-started')
|
|
151
|
-
|
|
245
|
+
|
|
246
|
+
return unsubscribe({ channel, consumerTag, logger })
|
|
152
247
|
} catch (err) {
|
|
153
|
-
logger.error(err, {
|
|
154
|
-
event: 'error',
|
|
155
|
-
})
|
|
248
|
+
logger.error(err, { event: 'error' })
|
|
156
249
|
throw err
|
|
157
250
|
}
|
|
158
251
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts a RabbitMQ Docker container for testing purposes.
|
|
5
|
+
*
|
|
6
|
+
* If a container with the same name already exists, it will be removed first.
|
|
7
|
+
* The container is started with the RabbitMQ Management plugin enabled and
|
|
8
|
+
* waits until the health check reports the container as healthy.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} options.containerName
|
|
12
|
+
* Docker container name.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} options.amqpPort
|
|
15
|
+
* Host port mapped to RabbitMQ AMQP port (5672).
|
|
16
|
+
*
|
|
17
|
+
* @param {number} options.uiPort
|
|
18
|
+
* Host port mapped to RabbitMQ Management UI port (15672).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} options.user
|
|
21
|
+
* Default RabbitMQ username.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} options.pass
|
|
24
|
+
* Default RabbitMQ password.
|
|
25
|
+
*
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*
|
|
28
|
+
* @throws {Error}
|
|
29
|
+
* Throws if the RabbitMQ container fails to become healthy within the timeout.
|
|
30
|
+
*/
|
|
31
|
+
export function startRabbit({ containerName, ...rest }) {
|
|
32
|
+
console.log(`[RabbitTest] Starting RabbitMQ...`)
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
execSync(
|
|
39
|
+
`docker run -d \
|
|
40
|
+
--name ${containerName} \
|
|
41
|
+
-e RABBITMQ_DEFAULT_USER=${rest.user} \
|
|
42
|
+
-e RABBITMQ_DEFAULT_PASS=${rest.pass} \
|
|
43
|
+
-p ${rest.amqpPort}:5672 \
|
|
44
|
+
-p ${rest.uiPort}:15672 \
|
|
45
|
+
--health-cmd="rabbitmq-diagnostics -q ping" \
|
|
46
|
+
--health-interval=5s \
|
|
47
|
+
--health-timeout=5s \
|
|
48
|
+
--health-retries=10 \
|
|
49
|
+
rabbitmq:3-management`,
|
|
50
|
+
{ stdio: 'inherit' },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
waitForRabbitHealthy(containerName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stops and removes a RabbitMQ Docker container.
|
|
58
|
+
*
|
|
59
|
+
* This function is safe to call even if the container does not exist.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} [containerName='rabbit-test']
|
|
62
|
+
* Docker container name.
|
|
63
|
+
*
|
|
64
|
+
* @returns {void}
|
|
65
|
+
*/
|
|
66
|
+
export function stopRabbit(containerName = 'rabbit-test') {
|
|
67
|
+
console.log(`[RabbitTest] Stopping RabbitMQ...`)
|
|
68
|
+
try {
|
|
69
|
+
execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`[RabbitTest] Failed to stop RabbitMQ: ${error}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Waits until the RabbitMQ Docker container reports a healthy status.
|
|
77
|
+
*
|
|
78
|
+
* Polls the container health status using `docker inspect` and retries
|
|
79
|
+
* for a fixed amount of time before failing.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} containerName
|
|
82
|
+
* Docker container name.
|
|
83
|
+
*
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*
|
|
86
|
+
* @throws {Error}
|
|
87
|
+
* Throws if the container does not become healthy within the timeout.
|
|
88
|
+
*/
|
|
89
|
+
function waitForRabbitHealthy(containerName) {
|
|
90
|
+
console.log(`[RabbitTest] Waiting for RabbitMQ to be healthy...`)
|
|
91
|
+
|
|
92
|
+
const maxRetries = 60
|
|
93
|
+
let retries = 0
|
|
94
|
+
|
|
95
|
+
while (retries < maxRetries) {
|
|
96
|
+
try {
|
|
97
|
+
const output = execSync(
|
|
98
|
+
`docker inspect --format='{{.State.Health.Status}}' ${containerName}`,
|
|
99
|
+
{ encoding: 'utf8' },
|
|
100
|
+
).trim()
|
|
101
|
+
|
|
102
|
+
if (output === 'healthy') {
|
|
103
|
+
console.log(`[RabbitTest] RabbitMQ is ready.`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (retries % 10 === 0 && retries > 0) {
|
|
108
|
+
console.log(
|
|
109
|
+
`[RabbitTest] Still waiting... Status: ${output} (${retries}/${maxRetries})`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
if (retries % 10 === 0 && retries > 0) {
|
|
114
|
+
console.log(
|
|
115
|
+
`[RabbitTest] Container not ready yet (${retries}/${maxRetries})`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
retries++
|
|
121
|
+
execSync(`sleep 1`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('[RabbitTest] Failed to start. Container logs:')
|
|
125
|
+
try {
|
|
126
|
+
execSync(`docker logs --tail 30 ${containerName}`, { stdio: 'inherit' })
|
|
127
|
+
} catch {}
|
|
128
|
+
|
|
129
|
+
throw new Error(
|
|
130
|
+
`[RabbitTest] RabbitMQ failed to become healthy within ${maxRetries} seconds`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Builds a RabbitMQ AMQP connection URI for local testing.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} options
|
|
138
|
+
* @param {string} options.user
|
|
139
|
+
* RabbitMQ username.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} options.pass
|
|
142
|
+
* RabbitMQ password.
|
|
143
|
+
*
|
|
144
|
+
* @param {number} options.port
|
|
145
|
+
* Host port mapped to RabbitMQ AMQP port.
|
|
146
|
+
*
|
|
147
|
+
* @returns {string}
|
|
148
|
+
* RabbitMQ AMQP connection URI.
|
|
149
|
+
*/
|
|
150
|
+
export function buildRabbitUri({ user, pass, port }) {
|
|
151
|
+
return `amqp://${user}:${pass}@localhost:${port}`
|
|
152
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { parseJsonIfString } from '../../src/core/parse-json-if-string.js'
|
|
4
|
+
|
|
5
|
+
describe('parseJsonIfString', () => {
|
|
6
|
+
it('parses a valid JSON string', () => {
|
|
7
|
+
const value = '{"a":1,"b":"test"}'
|
|
8
|
+
|
|
9
|
+
const result = parseJsonIfString(value)
|
|
10
|
+
|
|
11
|
+
expect(result).toEqual({ a: 1, b: 'test' })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns an object as-is', () => {
|
|
15
|
+
const value = { a: 1, b: 'test' }
|
|
16
|
+
|
|
17
|
+
const result = parseJsonIfString(value)
|
|
18
|
+
|
|
19
|
+
expect(result).toBe(value)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns an array as-is', () => {
|
|
23
|
+
const value = [1, 2, 3]
|
|
24
|
+
|
|
25
|
+
const result = parseJsonIfString(value)
|
|
26
|
+
|
|
27
|
+
expect(result).toBe(value)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns null as-is', () => {
|
|
31
|
+
const value = null
|
|
32
|
+
|
|
33
|
+
const result = parseJsonIfString(value)
|
|
34
|
+
|
|
35
|
+
expect(result).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns a number as-is', () => {
|
|
39
|
+
const value = 42
|
|
40
|
+
|
|
41
|
+
const result = parseJsonIfString(value)
|
|
42
|
+
|
|
43
|
+
expect(result).toBe(42)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws when JSON string is invalid', () => {
|
|
47
|
+
const value = '{"a":1,'
|
|
48
|
+
|
|
49
|
+
expect(() => parseJsonIfString(value)).toThrow(SyntaxError)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
|
|
3
|
+
import knex from 'knex'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
stopPostgres,
|
|
7
|
+
startPostgres,
|
|
8
|
+
buildPostgresUri,
|
|
9
|
+
} from '../../src/postgresql/start-stop-postgres-docker.js'
|
|
10
|
+
|
|
11
|
+
import { sqlPaginate } from '../../src/postgresql/paginate.js'
|
|
12
|
+
|
|
13
|
+
const PG_OPTIONS = {
|
|
14
|
+
port: 5443,
|
|
15
|
+
containerName: 'postgres-paginate-test',
|
|
16
|
+
user: 'testuser',
|
|
17
|
+
pass: 'testpass',
|
|
18
|
+
db: 'testdb',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
|
22
|
+
|
|
23
|
+
let db
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
startPostgres(PG_OPTIONS)
|
|
27
|
+
|
|
28
|
+
db = knex({
|
|
29
|
+
client: 'pg',
|
|
30
|
+
connection: DATABASE_URI,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
await db.schema.createTable('tenants', (table) => {
|
|
34
|
+
table.uuid('id').primary()
|
|
35
|
+
table.string('name').notNullable()
|
|
36
|
+
table.string('type').notNullable()
|
|
37
|
+
table.timestamp('created_at').notNullable()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
if (db) {
|
|
43
|
+
await db.destroy()
|
|
44
|
+
}
|
|
45
|
+
stopPostgres(PG_OPTIONS.containerName)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await db('tenants').truncate()
|
|
50
|
+
|
|
51
|
+
await db('tenants').insert([
|
|
52
|
+
{
|
|
53
|
+
id: '00000000-0000-0000-0000-000000000001',
|
|
54
|
+
name: 'Tenant A',
|
|
55
|
+
type: 'business',
|
|
56
|
+
created_at: new Date('2024-01-01'),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: '00000000-0000-0000-0000-000000000002',
|
|
60
|
+
name: 'Tenant B',
|
|
61
|
+
type: 'business',
|
|
62
|
+
created_at: new Date('2024-01-02'),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: '00000000-0000-0000-0000-000000000003',
|
|
66
|
+
name: 'Tenant C',
|
|
67
|
+
type: 'cpa',
|
|
68
|
+
created_at: new Date('2024-01-03'),
|
|
69
|
+
},
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('paginate integration', () => {
|
|
74
|
+
it('returns first page without ordering guarantees', async () => {
|
|
75
|
+
const result = await sqlPaginate({
|
|
76
|
+
db,
|
|
77
|
+
tableName: 'tenants',
|
|
78
|
+
page: 1,
|
|
79
|
+
limit: 2,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(result.totalCount).toBe(3)
|
|
83
|
+
expect(result.totalPages).toBe(2)
|
|
84
|
+
expect(result.currentPage).toBe(1)
|
|
85
|
+
expect(result.hasPrevious).toBe(false)
|
|
86
|
+
expect(result.hasNext).toBe(true)
|
|
87
|
+
|
|
88
|
+
expect(result.list).toHaveLength(2)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('returns second page without ordering guarantees', async () => {
|
|
92
|
+
const result = await sqlPaginate({
|
|
93
|
+
db,
|
|
94
|
+
tableName: 'tenants',
|
|
95
|
+
page: 2,
|
|
96
|
+
limit: 2,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(result.totalCount).toBe(3)
|
|
100
|
+
expect(result.totalPages).toBe(2)
|
|
101
|
+
expect(result.currentPage).toBe(2)
|
|
102
|
+
expect(result.hasPrevious).toBe(true)
|
|
103
|
+
expect(result.hasNext).toBe(false)
|
|
104
|
+
|
|
105
|
+
expect(result.list).toHaveLength(1)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('applies filters correctly without ordering guarantees', async () => {
|
|
109
|
+
const result = await sqlPaginate({
|
|
110
|
+
db,
|
|
111
|
+
tableName: 'tenants',
|
|
112
|
+
filter: { type: 'business' },
|
|
113
|
+
limit: 10,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(result.totalCount).toBe(2)
|
|
117
|
+
|
|
118
|
+
const names = result.list.map((t) => t.name).sort()
|
|
119
|
+
expect(names).toEqual(['Tenant A', 'Tenant B'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('supports custom ordering', async () => {
|
|
123
|
+
const result = await sqlPaginate({
|
|
124
|
+
db,
|
|
125
|
+
tableName: 'tenants',
|
|
126
|
+
orderBy: {
|
|
127
|
+
column: 'created_at',
|
|
128
|
+
direction: 'asc',
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(result.list.map((t) => t.name)).toEqual([
|
|
133
|
+
'Tenant A',
|
|
134
|
+
'Tenant B',
|
|
135
|
+
'Tenant C',
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('supports row mapping', async () => {
|
|
140
|
+
const result = await sqlPaginate({
|
|
141
|
+
db,
|
|
142
|
+
tableName: 'tenants',
|
|
143
|
+
mapRow: (row) => ({
|
|
144
|
+
...row,
|
|
145
|
+
mapped: true,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(result.list.length).toBeGreaterThan(0)
|
|
150
|
+
expect(result.list[0].mapped).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns empty list when no records match filter', async () => {
|
|
154
|
+
const result = await sqlPaginate({
|
|
155
|
+
db,
|
|
156
|
+
tableName: 'tenants',
|
|
157
|
+
filter: { type: 'non-existing' },
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(result.totalCount).toBe(0)
|
|
161
|
+
expect(result.totalPages).toBe(0)
|
|
162
|
+
expect(result.list).toEqual([])
|
|
163
|
+
expect(result.hasNext).toBe(false)
|
|
164
|
+
expect(result.hasPrevious).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -1,69 +1,139 @@
|
|
|
1
|
-
import
|
|
1
|
+
import pino from 'pino'
|
|
2
|
+
import { describe, it, beforeAll, afterAll, expect } from 'vitest'
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
stopRabbit,
|
|
6
|
+
startRabbit,
|
|
7
|
+
buildRabbitUri,
|
|
8
|
+
} from '../../src/rabbit-mq/start-stop-rabbitmq.js'
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
import { initializeQueue } from '../../src/rabbit-mq/index.js'
|
|
6
11
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
},
|
|
14
|
-
}
|
|
12
|
+
const RABBIT_CONTAINER = 'rabbit-test'
|
|
13
|
+
const AMQP_PORT = 5679
|
|
14
|
+
const UI_PORT = 15679
|
|
15
|
+
const USER = 'test'
|
|
16
|
+
const PASS = 'test'
|
|
17
|
+
const QUEUE = 'integration-test-queue'
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const log = pino({
|
|
20
|
+
level: 'silent',
|
|
21
|
+
})
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
async function waitForRabbitConnection({ uri, log, timeoutMs = 10000 }) {
|
|
24
|
+
const start = Date.now()
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
while (Date.now() - start < timeoutMs) {
|
|
27
|
+
try {
|
|
28
|
+
const rabbit = await initializeQueue({ host: uri, log })
|
|
29
|
+
return rabbit
|
|
30
|
+
} catch {
|
|
31
|
+
await sleep(300)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new Error('RabbitMQ AMQP endpoint did not become ready in time')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('RabbitMQ integration', () => {
|
|
39
|
+
// @ts-ignore
|
|
40
|
+
let rabbit
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
let unsubscribe
|
|
43
|
+
// @ts-ignore
|
|
44
|
+
let receivedMessages
|
|
23
45
|
|
|
24
46
|
beforeAll(async () => {
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
startRabbit({
|
|
48
|
+
containerName: RABBIT_CONTAINER,
|
|
49
|
+
amqpPort: AMQP_PORT,
|
|
50
|
+
uiPort: UI_PORT,
|
|
51
|
+
user: USER,
|
|
52
|
+
pass: PASS,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const uri = buildRabbitUri({
|
|
56
|
+
user: USER,
|
|
57
|
+
pass: PASS,
|
|
58
|
+
port: AMQP_PORT,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
rabbit = await waitForRabbitConnection({
|
|
62
|
+
uri,
|
|
63
|
+
log,
|
|
64
|
+
})
|
|
65
|
+
}, 60_000)
|
|
27
66
|
|
|
28
|
-
|
|
29
|
-
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
try {
|
|
69
|
+
// @ts-ignore
|
|
70
|
+
if (unsubscribe) {
|
|
71
|
+
await unsubscribe()
|
|
72
|
+
}
|
|
73
|
+
// @ts-ignore
|
|
74
|
+
if (rabbit) {
|
|
75
|
+
await rabbit.close()
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
stopRabbit(RABBIT_CONTAINER)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should publish, consume, unsubscribe, and stop consuming', async () => {
|
|
83
|
+
receivedMessages = []
|
|
84
|
+
|
|
85
|
+
// @ts-ignore
|
|
86
|
+
unsubscribe = await rabbit.subscribe({
|
|
87
|
+
queue: QUEUE,
|
|
88
|
+
// @ts-ignore
|
|
30
89
|
onReceive: async (data) => {
|
|
31
|
-
|
|
90
|
+
receivedMessages.push(data)
|
|
32
91
|
},
|
|
33
92
|
})
|
|
34
|
-
})
|
|
35
93
|
|
|
36
|
-
|
|
37
|
-
await
|
|
38
|
-
await
|
|
39
|
-
expect(received).toEqual(testMessage)
|
|
40
|
-
})
|
|
41
|
-
})
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
await rabbit.publish(QUEUE, { step: 1 })
|
|
96
|
+
await waitFor(() => receivedMessages.length === 1)
|
|
42
97
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const env = {
|
|
46
|
-
RABBIT_PORT: 5672,
|
|
47
|
-
RABBIT_HOST: '0.0.0.0',
|
|
48
|
-
RABBIT_USERNAME: 'botq',
|
|
49
|
-
RABBIT_PASSWORD: 'botq',
|
|
50
|
-
_RABBIT_HOST: '0.0.0.0',
|
|
51
|
-
RABBIT_PROTOCOL: 'amqp',
|
|
52
|
-
}
|
|
98
|
+
// @ts-ignore
|
|
99
|
+
expect(receivedMessages).toEqual([{ step: 1 }])
|
|
53
100
|
|
|
54
|
-
|
|
55
|
-
expect(uri).toBe('amqp://botq:botq@0.0.0.0:5672')
|
|
56
|
-
})
|
|
101
|
+
await unsubscribe()
|
|
57
102
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
RABBIT_USERNAME: 'botq',
|
|
63
|
-
RABBIT_PASSWORD: 'botq',
|
|
64
|
-
}
|
|
103
|
+
// @ts-ignore
|
|
104
|
+
await rabbit.publish(QUEUE, { step: 2 })
|
|
105
|
+
|
|
106
|
+
await sleep(1000)
|
|
65
107
|
|
|
66
|
-
|
|
67
|
-
expect(
|
|
108
|
+
// @ts-ignore
|
|
109
|
+
expect(receivedMessages).toEqual([{ step: 1 }])
|
|
68
110
|
})
|
|
69
111
|
})
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Waits until a condition becomes true or times out.
|
|
115
|
+
*
|
|
116
|
+
* @param {() => boolean} predicate
|
|
117
|
+
* @param {number} timeoutMs
|
|
118
|
+
*/
|
|
119
|
+
async function waitFor(predicate, timeoutMs = 5000) {
|
|
120
|
+
const start = Date.now()
|
|
121
|
+
|
|
122
|
+
while (Date.now() - start < timeoutMs) {
|
|
123
|
+
if (predicate()) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
await sleep(50)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error('Condition not met within timeout')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Sleeps for the given number of milliseconds.
|
|
134
|
+
*
|
|
135
|
+
* @param {number} ms
|
|
136
|
+
*/
|
|
137
|
+
function sleep(ms) {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
139
|
+
}
|
|
@@ -80,15 +80,13 @@ function isConnected(port) {
|
|
|
80
80
|
*/
|
|
81
81
|
function waitForMongo(port) {
|
|
82
82
|
console.log(`[MongoTest] Waiting for MongoDB to be ready...`)
|
|
83
|
-
const maxRetries =
|
|
83
|
+
const maxRetries = 60
|
|
84
84
|
let retries = 0
|
|
85
85
|
let connected = false
|
|
86
86
|
|
|
87
87
|
while (!connected && retries < maxRetries) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
retries++
|
|
91
|
-
} catch {
|
|
88
|
+
connected = isConnected(port)
|
|
89
|
+
if (!connected) {
|
|
92
90
|
retries++
|
|
93
91
|
execSync(`sleep 1`)
|
|
94
92
|
}
|
package/types/mongodb/index.d.ts
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function sqlPaginate({
|
|
2
|
+
db,
|
|
3
|
+
mapRow,
|
|
4
|
+
orderBy,
|
|
5
|
+
page,
|
|
6
|
+
limit,
|
|
7
|
+
tableName,
|
|
8
|
+
filter,
|
|
9
|
+
}?: {
|
|
10
|
+
db: any
|
|
11
|
+
tableName: string
|
|
12
|
+
page?: number
|
|
13
|
+
limit?: number
|
|
14
|
+
filter?: any
|
|
15
|
+
orderBy?: {
|
|
16
|
+
column: string
|
|
17
|
+
direction?: 'asc' | 'desc'
|
|
18
|
+
}
|
|
19
|
+
}): Promise<{
|
|
20
|
+
list: any[]
|
|
21
|
+
totalCount: number
|
|
22
|
+
totalPages: number
|
|
23
|
+
currentPage: number
|
|
24
|
+
hasNext: boolean
|
|
25
|
+
hasPrevious: boolean
|
|
26
|
+
}>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts a RabbitMQ Docker container for testing purposes.
|
|
3
|
+
*
|
|
4
|
+
* If a container with the same name already exists, it will be removed first.
|
|
5
|
+
* The container is started with the RabbitMQ Management plugin enabled and
|
|
6
|
+
* waits until the health check reports the container as healthy.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} options.containerName
|
|
10
|
+
* Docker container name.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} options.amqpPort
|
|
13
|
+
* Host port mapped to RabbitMQ AMQP port (5672).
|
|
14
|
+
*
|
|
15
|
+
* @param {number} options.uiPort
|
|
16
|
+
* Host port mapped to RabbitMQ Management UI port (15672).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} options.user
|
|
19
|
+
* Default RabbitMQ username.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} options.pass
|
|
22
|
+
* Default RabbitMQ password.
|
|
23
|
+
*
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*
|
|
26
|
+
* @throws {Error}
|
|
27
|
+
* Throws if the RabbitMQ container fails to become healthy within the timeout.
|
|
28
|
+
*/
|
|
29
|
+
export function startRabbit({
|
|
30
|
+
containerName,
|
|
31
|
+
...rest
|
|
32
|
+
}: {
|
|
33
|
+
containerName: string
|
|
34
|
+
amqpPort: number
|
|
35
|
+
uiPort: number
|
|
36
|
+
user: string
|
|
37
|
+
pass: string
|
|
38
|
+
}): void
|
|
39
|
+
/**
|
|
40
|
+
* Stops and removes a RabbitMQ Docker container.
|
|
41
|
+
*
|
|
42
|
+
* This function is safe to call even if the container does not exist.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} [containerName='rabbit-test']
|
|
45
|
+
* Docker container name.
|
|
46
|
+
*
|
|
47
|
+
* @returns {void}
|
|
48
|
+
*/
|
|
49
|
+
export function stopRabbit(containerName?: string): void
|
|
50
|
+
/**
|
|
51
|
+
* Builds a RabbitMQ AMQP connection URI for local testing.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} options
|
|
54
|
+
* @param {string} options.user
|
|
55
|
+
* RabbitMQ username.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} options.pass
|
|
58
|
+
* RabbitMQ password.
|
|
59
|
+
*
|
|
60
|
+
* @param {number} options.port
|
|
61
|
+
* Host port mapped to RabbitMQ AMQP port.
|
|
62
|
+
*
|
|
63
|
+
* @returns {string}
|
|
64
|
+
* RabbitMQ AMQP connection URI.
|
|
65
|
+
*/
|
|
66
|
+
export function buildRabbitUri({
|
|
67
|
+
user,
|
|
68
|
+
pass,
|
|
69
|
+
port,
|
|
70
|
+
}: {
|
|
71
|
+
user: string
|
|
72
|
+
pass: string
|
|
73
|
+
port: number
|
|
74
|
+
}): string
|