core-services-sdk 1.3.57 → 1.3.59
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/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 +102 -21
- package/src/rabbit-mq/start-stop-rabbitmq.js +152 -0
- package/tests/postgresql/paginate.integration.test.js +165 -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.59",
|
|
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/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
|
-
* @param {number} [options.prefetch=1] - Max unacked messages per consumer (default: 1)
|
|
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.
|
|
101
168
|
*
|
|
102
|
-
* @
|
|
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.
|
|
174
|
+
*
|
|
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,
|
|
@@ -113,14 +193,17 @@ export const subscribeToQueue = async ({
|
|
|
113
193
|
|
|
114
194
|
try {
|
|
115
195
|
await channel.assertQueue(queue, { durable: true })
|
|
116
|
-
|
|
196
|
+
|
|
197
|
+
if (prefetch) {
|
|
198
|
+
await channel.prefetch(prefetch)
|
|
199
|
+
}
|
|
117
200
|
|
|
118
201
|
const { consumerTag } = await channel.consume(queue, async (msgInfo) => {
|
|
119
202
|
if (!msgInfo) {
|
|
120
203
|
return
|
|
121
204
|
}
|
|
122
|
-
const t0 = Date.now()
|
|
123
205
|
|
|
206
|
+
const t0 = Date.now()
|
|
124
207
|
const { msgId, data, correlationId } = parseMessage(msgInfo)
|
|
125
208
|
const child = logger.child({ msgId, correlationId })
|
|
126
209
|
|
|
@@ -131,28 +214,26 @@ export const subscribeToQueue = async ({
|
|
|
131
214
|
await onReceive(data, correlationId)
|
|
132
215
|
channel.ack(msgInfo)
|
|
133
216
|
|
|
134
|
-
child.info({
|
|
135
|
-
event: 'ok',
|
|
136
|
-
ms: Date.now() - t0,
|
|
137
|
-
})
|
|
138
|
-
return
|
|
217
|
+
child.info({ event: 'ok', ms: Date.now() - t0 })
|
|
139
218
|
} catch (err) {
|
|
140
|
-
|
|
219
|
+
if (nackOnError) {
|
|
220
|
+
channel.nack(msgInfo)
|
|
221
|
+
} else {
|
|
222
|
+
channel.ack(msgInfo)
|
|
223
|
+
}
|
|
141
224
|
|
|
142
225
|
child.error(err, {
|
|
143
226
|
event: 'error',
|
|
144
227
|
ms: Date.now() - t0,
|
|
145
228
|
})
|
|
146
|
-
return
|
|
147
229
|
}
|
|
148
230
|
})
|
|
149
231
|
|
|
150
232
|
logger.debug({ consumerTag }, 'consumer-started')
|
|
151
|
-
|
|
233
|
+
|
|
234
|
+
return unsubscribe({ channel, consumerTag, logger })
|
|
152
235
|
} catch (err) {
|
|
153
|
-
logger.error(err, {
|
|
154
|
-
event: 'error',
|
|
155
|
-
})
|
|
236
|
+
logger.error(err, { event: 'error' })
|
|
156
237
|
throw err
|
|
157
238
|
}
|
|
158
239
|
}
|
|
@@ -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,165 @@
|
|
|
1
|
+
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
|
|
2
|
+
import knex from 'knex'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
stopPostgres,
|
|
6
|
+
startPostgres,
|
|
7
|
+
buildPostgresUri,
|
|
8
|
+
} from '../../src/postgresql/start-stop-postgres-docker.js'
|
|
9
|
+
|
|
10
|
+
import { sqlPaginate } from '../../src/postgresql/paginate.js'
|
|
11
|
+
|
|
12
|
+
const PG_OPTIONS = {
|
|
13
|
+
port: 5443,
|
|
14
|
+
containerName: 'postgres-paginate-test',
|
|
15
|
+
user: 'testuser',
|
|
16
|
+
pass: 'testpass',
|
|
17
|
+
db: 'testdb',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
|
21
|
+
|
|
22
|
+
let db
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
startPostgres(PG_OPTIONS)
|
|
26
|
+
|
|
27
|
+
db = knex({
|
|
28
|
+
client: 'pg',
|
|
29
|
+
connection: DATABASE_URI,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
await db.schema.createTable('tenants', (table) => {
|
|
33
|
+
table.uuid('id').primary()
|
|
34
|
+
table.string('name').notNullable()
|
|
35
|
+
table.string('type').notNullable()
|
|
36
|
+
table.timestamp('created_at').notNullable()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
if (db) {
|
|
42
|
+
await db.destroy()
|
|
43
|
+
}
|
|
44
|
+
stopPostgres(PG_OPTIONS.containerName)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
await db('tenants').truncate()
|
|
49
|
+
|
|
50
|
+
await db('tenants').insert([
|
|
51
|
+
{
|
|
52
|
+
id: '00000000-0000-0000-0000-000000000001',
|
|
53
|
+
name: 'Tenant A',
|
|
54
|
+
type: 'business',
|
|
55
|
+
created_at: new Date('2024-01-01'),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: '00000000-0000-0000-0000-000000000002',
|
|
59
|
+
name: 'Tenant B',
|
|
60
|
+
type: 'business',
|
|
61
|
+
created_at: new Date('2024-01-02'),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: '00000000-0000-0000-0000-000000000003',
|
|
65
|
+
name: 'Tenant C',
|
|
66
|
+
type: 'cpa',
|
|
67
|
+
created_at: new Date('2024-01-03'),
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('paginate integration', () => {
|
|
73
|
+
it('returns first page without ordering guarantees', async () => {
|
|
74
|
+
const result = await sqlPaginate({
|
|
75
|
+
db,
|
|
76
|
+
tableName: 'tenants',
|
|
77
|
+
page: 1,
|
|
78
|
+
limit: 2,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(result.totalCount).toBe(3)
|
|
82
|
+
expect(result.totalPages).toBe(2)
|
|
83
|
+
expect(result.currentPage).toBe(1)
|
|
84
|
+
expect(result.hasPrevious).toBe(false)
|
|
85
|
+
expect(result.hasNext).toBe(true)
|
|
86
|
+
|
|
87
|
+
expect(result.list).toHaveLength(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns second page without ordering guarantees', async () => {
|
|
91
|
+
const result = await sqlPaginate({
|
|
92
|
+
db,
|
|
93
|
+
tableName: 'tenants',
|
|
94
|
+
page: 2,
|
|
95
|
+
limit: 2,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(result.totalCount).toBe(3)
|
|
99
|
+
expect(result.totalPages).toBe(2)
|
|
100
|
+
expect(result.currentPage).toBe(2)
|
|
101
|
+
expect(result.hasPrevious).toBe(true)
|
|
102
|
+
expect(result.hasNext).toBe(false)
|
|
103
|
+
|
|
104
|
+
expect(result.list).toHaveLength(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('applies filters correctly without ordering guarantees', async () => {
|
|
108
|
+
const result = await sqlPaginate({
|
|
109
|
+
db,
|
|
110
|
+
tableName: 'tenants',
|
|
111
|
+
filter: { type: 'business' },
|
|
112
|
+
limit: 10,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(result.totalCount).toBe(2)
|
|
116
|
+
|
|
117
|
+
const names = result.list.map((t) => t.name).sort()
|
|
118
|
+
expect(names).toEqual(['Tenant A', 'Tenant B'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('supports custom ordering', async () => {
|
|
122
|
+
const result = await sqlPaginate({
|
|
123
|
+
db,
|
|
124
|
+
tableName: 'tenants',
|
|
125
|
+
orderBy: {
|
|
126
|
+
column: 'created_at',
|
|
127
|
+
direction: 'asc',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(result.list.map((t) => t.name)).toEqual([
|
|
132
|
+
'Tenant A',
|
|
133
|
+
'Tenant B',
|
|
134
|
+
'Tenant C',
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('supports row mapping', async () => {
|
|
139
|
+
const result = await sqlPaginate({
|
|
140
|
+
db,
|
|
141
|
+
tableName: 'tenants',
|
|
142
|
+
mapRow: (row) => ({
|
|
143
|
+
...row,
|
|
144
|
+
mapped: true,
|
|
145
|
+
}),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(result.list.length).toBeGreaterThan(0)
|
|
149
|
+
expect(result.list[0].mapped).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns empty list when no records match filter', async () => {
|
|
153
|
+
const result = await sqlPaginate({
|
|
154
|
+
db,
|
|
155
|
+
tableName: 'tenants',
|
|
156
|
+
filter: { type: 'non-existing' },
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(result.totalCount).toBe(0)
|
|
160
|
+
expect(result.totalPages).toBe(0)
|
|
161
|
+
expect(result.list).toEqual([])
|
|
162
|
+
expect(result.hasNext).toBe(false)
|
|
163
|
+
expect(result.hasPrevious).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -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
|