core-services-sdk 1.3.56 → 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.
@@ -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
@@ -4,7 +4,6 @@
4
4
  "endOfLine": "lf",
5
5
  "requirePragma": false,
6
6
  "insertPragma": false,
7
- "jsxBracketSameLine": false,
8
7
  "jsxSingleQuote": true,
9
8
  "printWidth": 80,
10
9
  "quoteProps": "consistent",
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.56",
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": {
@@ -0,0 +1,38 @@
1
+ import { Context } from '../../util/context.js'
2
+ /**
3
+ * Creates a request-scoped logger enriched with contextual metadata.
4
+ *
5
+ * The logger is derived from the provided Pino logger and augmented with:
6
+ * - correlationId (from Context)
7
+ * - client IP (from Context)
8
+ * - user agent (from Context)
9
+ * - operation identifier in the form: "<METHOD> <URL>"
10
+ *
11
+ * Intended to be used per incoming Fastify request, typically at the beginning
12
+ * of a request lifecycle, so all subsequent logs automatically include
13
+ * request-specific context.
14
+ *
15
+ * @param {import('fastify').FastifyRequest} request
16
+ * The Fastify request object.
17
+ *
18
+ * @param {import('pino').Logger} log
19
+ * Base Pino logger instance.
20
+ *
21
+ * @returns {import('pino').Logger}
22
+ * A child Pino logger enriched with request and context metadata.
23
+ *
24
+ * @example
25
+ * const requestLog = createRequestLogger(request, log, Context)
26
+ * requestLog.info('Handling request')
27
+ */
28
+
29
+ export const createRequestLogger = (request, log) => {
30
+ const { correlationId, ip, userAgent } = Context?.all() || {}
31
+
32
+ return log.child({
33
+ ip,
34
+ userAgent,
35
+ correlationId,
36
+ op: `${request.method} ${request?.url || request?.routeOptions?.url || request.raw.url}`,
37
+ })
38
+ }
package/src/http/index.js CHANGED
@@ -2,3 +2,4 @@ export * from './http.js'
2
2
  export * from './HttpError.js'
3
3
  export * from './http-method.js'
4
4
  export * from './responseType.js'
5
+ export * from './helpers/create-request-logger.js'
@@ -3,3 +3,4 @@ export * from './paginate.js'
3
3
  export * from './dsl-to-mongo.js'
4
4
  export * from './initialize-mongodb.js'
5
5
  export * from './validate-mongo-uri.js'
6
+ export { paginate as mongoPaginate } from './paginate.js'
@@ -1,3 +1,4 @@
1
+ export * from './paginate.js'
1
2
  export * from './connect-to-pg.js'
2
3
  export * from './validate-schema.js'
3
4
  export * from './start-stop-postgres-docker.js'
@@ -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 = 20
127
+ const maxRetries = 60
128
128
  let retries = 0
129
129
  let ready = false
130
130
 
@@ -1 +1,2 @@
1
1
  export * from './rabbit-mq.js'
2
+ export * from './start-stop-rabbitmq.js'
@@ -89,17 +89,97 @@ const parseMessage = (msgInfo) => {
89
89
  }
90
90
 
91
91
  /**
92
- * Subscribes to a queue to receive messages.
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 - RabbitMQ channel
96
- * @param {string} options.queue - Queue name to subscribe to
97
- * @param {(data: any, correlationId?: string) => Promise<void>} options.onReceive - Async handler
98
- * @param {import('pino').Logger} options.log - Base logger
99
- * @param {boolean} [options.nackOnError=false] - Whether to nack the message on error (default: false)
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
- * @returns {Promise<string>} Returns the consumer tag for later cancellation
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
- !!prefetch && (await channel.prefetch(prefetch))
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
- nackOnError ? channel.nack(msgInfo) : channel.ack(msgInfo)
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
- return consumerTag
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
+ }
@@ -3,79 +3,111 @@ import { AsyncLocalStorage } from 'node:async_hooks'
3
3
  const als = new AsyncLocalStorage()
4
4
 
5
5
  /**
6
- * Context utility built on top of Node.js AsyncLocalStorage.
7
- * Provides a per-request (or per-async-chain) storage mechanism that
8
- * allows passing metadata (like correlation IDs, user info, tenant ID, etc.)
9
- * without explicitly threading it through every function call.
6
+ * Represents the data stored in the async context for a single execution flow.
7
+ *
8
+ * This object is propagated automatically across async boundaries
9
+ * using Node.js AsyncLocalStorage.
10
+ *
11
+ * It defines the contract between producers (who set values)
12
+ * and consumers (who read values) of the context.
13
+ *
14
+ * @typedef {Object} ContextStore
15
+ *
16
+ * @property {string} [correlationId] - Unique identifier for request or operation tracing.
17
+ * @property {string} [ip] - Client IP address.
18
+ * @property {string} [userAgent] - Client user agent string.
19
+ * @property {string} [tenantId] - Active tenant identifier.
20
+ * @property {string} [userId] - Authenticated user identifier.
10
21
  */
11
22
 
12
- export const Context = {
23
+ /**
24
+ * Async execution context manager built on top of Node.js AsyncLocalStorage.
25
+ *
26
+ * This class provides a thin, static API for storing and accessing
27
+ * request-scoped (or async-chain-scoped) metadata such as correlation IDs,
28
+ * user information, tenant identifiers, and similar data.
29
+ *
30
+ * The context is bound to the current async execution chain using
31
+ * AsyncLocalStorage and is automatically propagated across `await` boundaries.
32
+ *
33
+ * This class is intentionally static and acts as a singleton wrapper
34
+ * around AsyncLocalStorage.
35
+ */
36
+ export class Context {
13
37
  /**
14
- * Run a callback within a given context store.
15
- * Everything `await`ed or invoked inside this callback will have access
16
- * to the provided store via {@link Context.get}, {@link Context.set}, or {@link Context.all}.
38
+ * Run a callback within a given async context store.
39
+ *
40
+ * All asynchronous operations spawned inside the callback
41
+ * will have access to the provided store via {@link Context.get},
42
+ * {@link Context.set}, or {@link Context.all}.
17
43
  *
18
44
  * @template T
19
- * @param {Record<string, any>} store
45
+ * @param {ContextStore} store - Initial context store for this execution.
20
46
  * @param {() => T} callback - Function to execute inside the context.
21
47
  * @returns {T} The return value of the callback (sync or async).
22
48
  *
23
49
  * @example
24
50
  * Context.run(
25
- * { correlationId: 'abc123' },
51
+ * { correlationId: 'abc123', userId: 'usr_1' },
26
52
  * async () => {
27
53
  * console.log(Context.get('correlationId')) // "abc123"
28
54
  * }
29
55
  * )
30
56
  */
31
- run(store, callback) {
57
+ static run(store, callback) {
32
58
  return als.run(store, callback)
33
- },
59
+ }
34
60
 
35
61
  /**
36
62
  * Retrieve a single value from the current async context store.
37
63
  *
38
- * @template T
39
- * @param {string} key - The key of the value to retrieve.
40
- * @returns {T|undefined} The stored value, or `undefined` if no store exists or key not found.
64
+ * If called outside of an active {@link Context.run},
65
+ * this method returns `undefined`.
66
+ *
67
+ * @template {keyof ContextStore} K
68
+ * @param {K} key - Context property name.
69
+ * @returns {ContextStore[K] | undefined} The stored value, if present.
41
70
  *
42
71
  * @example
43
- * const userId = Context.get('userId')
72
+ * const tenantId = Context.get('tenantId')
44
73
  */
45
- get(key) {
74
+ static get(key) {
46
75
  const store = als.getStore()
47
76
  return store?.[key]
48
- },
77
+ }
49
78
 
50
79
  /**
51
- * Set a single key-value pair in the current async context store.
52
- * If there is no active store (i.e. outside of a {@link Context.run}),
53
- * this function does nothing.
80
+ * Set a single key-value pair on the current async context store.
81
+ *
82
+ * If called outside of an active {@link Context.run},
83
+ * this method is a no-op.
54
84
  *
55
- * @param {string} key - The key under which to store the value.
56
- * @param {any} value - The value to store.
85
+ * @template {keyof ContextStore} K
86
+ * @param {K} key - Context property name.
87
+ * @param {ContextStore[K]} value - Value to store.
57
88
  *
58
89
  * @example
59
90
  * Context.set('tenantId', 'tnt_1234')
60
91
  */
61
- set(key, value) {
92
+ static set(key, value) {
62
93
  const store = als.getStore()
63
94
  if (store) {
64
95
  store[key] = value
65
96
  }
66
- },
97
+ }
67
98
 
68
99
  /**
69
- * Get the entire store object for the current async context.
100
+ * Get the full context store for the current async execution.
101
+ *
102
+ * If no context is active, an empty object is returned.
70
103
  *
71
- * @returns {Record<string, any>} The current store object,
72
- * or an empty object if no store exists.
104
+ * @returns {ContextStore}
73
105
  *
74
106
  * @example
75
- * const all = Context.all()
76
- * console.log(all) // { correlationId: 'abc123', userId: 'usr_789' }
107
+ * const ctx = Context.all()
108
+ * console.log(ctx.correlationId)
77
109
  */
78
- all() {
110
+ static all() {
79
111
  return als.getStore() || {}
80
- },
112
+ }
81
113
  }