core-services-sdk 1.3.85 → 1.3.87

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.85",
3
+ "version": "1.3.87",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
package/src/http/http.js CHANGED
@@ -183,6 +183,45 @@ const getResponsePayload = async (response, responseType) => {
183
183
  }
184
184
  }
185
185
 
186
+ /**
187
+ * Normalizes the request body before sending it via fetch.
188
+ *
189
+ * Ensures backward compatibility while allowing additional body types.
190
+ * - undefined/null → no body sent
191
+ * - string → sent as-is
192
+ * - URLSearchParams → sent as-is (fetch serializes automatically)
193
+ * - FormData → sent as-is (fetch sets multipart boundary automatically)
194
+ * - ArrayBuffer / Blob → sent as-is
195
+ * - object / array → JSON.stringify applied and Content-Type ensured
196
+ *
197
+ * @param {any} body
198
+ * @param {Record<string,string>} headers
199
+ * @returns {{ body:any, headers:Record<string,string> }}
200
+ */
201
+ const prepareRequestBody = (body, headers = {}) => {
202
+ if (body === undefined || body === null) {
203
+ return { body: undefined, headers }
204
+ }
205
+
206
+ if (
207
+ typeof body === 'string' ||
208
+ body instanceof URLSearchParams ||
209
+ body instanceof FormData ||
210
+ body instanceof ArrayBuffer ||
211
+ body instanceof Blob
212
+ ) {
213
+ return { body, headers }
214
+ }
215
+
216
+ return {
217
+ body: JSON.stringify(body),
218
+ headers: {
219
+ 'Content-Type': 'application/json',
220
+ ...headers,
221
+ },
222
+ }
223
+ }
224
+
186
225
  /**
187
226
  * Sends an HTTP GET request.
188
227
  *
@@ -219,11 +258,15 @@ export const post = async ({
219
258
  extraParams = {},
220
259
  expectedType = ResponseType.json,
221
260
  }) => {
261
+ const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
262
+ body,
263
+ headers,
264
+ )
222
265
  const response = await fetch(url, {
223
266
  ...extraParams,
224
267
  method: HTTP_METHODS.POST,
225
- headers: { ...JSON_HEADER, ...headers },
226
- body: JSON.stringify(body),
268
+ headers: { ...JSON_HEADER, ...preparedHeaders },
269
+ body: preparedBody,
227
270
  })
228
271
  await checkStatus(response)
229
272
  return getResponsePayload(response, expectedType)
@@ -243,11 +286,15 @@ export const put = async ({
243
286
  extraParams = {},
244
287
  expectedType = ResponseType.json,
245
288
  }) => {
289
+ const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
290
+ body,
291
+ headers,
292
+ )
246
293
  const response = await fetch(url, {
247
294
  ...extraParams,
248
295
  method: HTTP_METHODS.PUT,
249
- headers: { ...JSON_HEADER, ...headers },
250
- body: expectedType === ResponseType.json ? JSON.stringify(body) : body,
296
+ headers: { ...JSON_HEADER, ...preparedHeaders },
297
+ body: preparedBody,
251
298
  })
252
299
  await checkStatus(response)
253
300
  return getResponsePayload(response, expectedType)
@@ -267,11 +314,15 @@ export const patch = async ({
267
314
  extraParams = {},
268
315
  expectedType = ResponseType.json,
269
316
  }) => {
317
+ const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
318
+ body,
319
+ headers,
320
+ )
270
321
  const response = await fetch(url, {
271
322
  ...extraParams,
272
323
  method: HTTP_METHODS.PATCH,
273
- headers: { ...JSON_HEADER, ...headers },
274
- body: JSON.stringify(body),
324
+ headers: { ...JSON_HEADER, ...preparedHeaders },
325
+ body: preparedBody,
275
326
  })
276
327
  await checkStatus(response)
277
328
  return getResponsePayload(response, expectedType)
@@ -291,11 +342,15 @@ export const deleteApi = async ({
291
342
  extraParams = {},
292
343
  expectedType = ResponseType.json,
293
344
  }) => {
345
+ const { body: preparedBody, headers: preparedHeaders } = prepareRequestBody(
346
+ body,
347
+ headers,
348
+ )
294
349
  const response = await fetch(url, {
295
350
  ...extraParams,
296
351
  method: HTTP_METHODS.DELETE,
297
- headers: { ...JSON_HEADER, ...headers },
298
- ...(body ? { body: JSON.stringify(body) } : {}),
352
+ headers: { ...JSON_HEADER, ...preparedHeaders },
353
+ ...(preparedBody ? { body: preparedBody } : {}),
299
354
  })
300
355
  await checkStatus(response)
301
356
  return getResponsePayload(response, expectedType)
@@ -328,8 +383,9 @@ export const head = async ({ url, headers = {}, extraParams = {} }) => {
328
383
  * put: (options: HttpPutOptions) => Promise<any>,
329
384
  * post: (options: HttpPostOptions) => Promise<any>,
330
385
  * patch: (options: HttpPatchOptions) => Promise<any>,
331
- * deleteApi: (options: HttpDeleteOptions) => Promise<any>,
332
- * head: (options: HttpHeadOptions) => Promise<Response>
386
+ * head: (options: HttpHeadOptions) => Promise<Response>,
387
+ * deleteApi: (options: HttpDeleteOptions) => Promise<any>
388
+ *
333
389
  * }}
334
390
  */
335
391
 
@@ -337,7 +393,7 @@ export const http = {
337
393
  get,
338
394
  put,
339
395
  post,
396
+ head,
340
397
  patch,
341
398
  deleteApi,
342
- head,
343
399
  }
@@ -2,22 +2,53 @@ import { getTableNameFromQuery } from '../core/get-table-name.js'
2
2
 
3
3
  /**
4
4
  * @typedef {Object} OrderByItem
5
- * @property {string} column - Column name to order by
6
- * @property {'asc' | 'desc'} [direction='asc'] - Order direction
5
+ * @property {string} column
6
+ * @property {'asc' | 'desc'} [direction='asc']
7
7
  */
8
8
 
9
9
  const ALLOWED_DIRECTIONS = ['asc', 'desc']
10
10
 
11
+ /**
12
+ * Returns true if the column is a SQL expression or aggregate function call.
13
+ * These must be passed to orderByRaw — Knex's .orderBy() would quote them.
14
+ *
15
+ * Examples: "count(*)", "lower(name)", "max(created_at)"
16
+ *
17
+ * @param {string} column
18
+ * @returns {boolean}
19
+ */
20
+ function isSqlExpression(column) {
21
+ return column.includes('(') || column.includes(')')
22
+ }
23
+
24
+ /**
25
+ * Returns true if the column is already table-qualified.
26
+ * These should be passed as-is to .orderBy() — no auto-prefix needed.
27
+ *
28
+ * Examples: "tenants.name", "roles.role_type"
29
+ *
30
+ * @param {string} column
31
+ * @returns {boolean}
32
+ */
33
+ function isQualified(column) {
34
+ return column.includes('.')
35
+ }
36
+
11
37
  /**
12
38
  * Applies ORDER BY clause(s) to a Knex query builder.
13
39
  *
14
- * Supports a single orderBy object or an array of orderBy objects.
15
- * Validates order direction to prevent invalid SQL.
40
+ * Supports single or multiple order definitions.
41
+ *
42
+ * Column handling:
43
+ * - SQL expressions (e.g. "lower(name)", "count(*)") → orderByRaw, never quoted
44
+ * - Already-qualified columns (e.g. "tenants.name") → orderBy as-is
45
+ * - Plain column names (e.g. "name") → auto-prefixed with base table name
46
+ * to avoid ambiguity in joined queries, matching applyFilter behaviour
16
47
  *
17
48
  * @param {Object} params
18
- * @param {import('knex').Knex.QueryBuilder} params.query - Knex query builder instance
19
- * @param {OrderByItem | OrderByItem[]} params.orderBy - Order by definition(s)
20
- * @returns {import('knex').Knex.QueryBuilder} The modified query builder
49
+ * @param {import('knex').Knex.QueryBuilder} params.query
50
+ * @param {OrderByItem | OrderByItem[]} params.orderBy
51
+ * @returns {import('knex').Knex.QueryBuilder}
21
52
  */
22
53
  export function applyOrderBy({ query, orderBy }) {
23
54
  if (!orderBy) {
@@ -27,19 +58,27 @@ export function applyOrderBy({ query, orderBy }) {
27
58
  const tableName = getTableNameFromQuery(query)
28
59
  const orderByArray = [].concat(orderBy)
29
60
 
30
- return orderByArray.reduce((query, item) => {
31
- if (!item?.column) {
32
- return query
61
+ return orderByArray.reduce((queryBuilder, item) => {
62
+ if (!item || !item.column) {
63
+ return queryBuilder
33
64
  }
34
65
 
35
- const direction = item.direction || 'asc'
66
+ const direction = (item.direction || 'asc').toLowerCase()
36
67
 
37
68
  if (!ALLOWED_DIRECTIONS.includes(direction)) {
38
69
  throw new Error(`Invalid order direction: ${direction}`)
39
70
  }
40
71
 
41
- const column = tableName ? `${tableName}.${item.column}` : item.column
72
+ // SQL expressions must bypass Knex's column quoting
73
+ if (isSqlExpression(item.column)) {
74
+ return queryBuilder.orderByRaw(`${item.column} ${direction}`)
75
+ }
76
+
77
+ const column =
78
+ tableName && !isQualified(item.column)
79
+ ? `${tableName}.${item.column}`
80
+ : item.column
42
81
 
43
- return query.orderBy(column, direction)
82
+ return queryBuilder.orderBy(column, direction)
44
83
  }, query)
45
84
  }
@@ -67,6 +67,11 @@ import { normalizeNumberOrDefault } from '../../core/normalize-premitives-types-
67
67
  * The list of records for the current page.
68
68
  * Rows are mapped using `mapRow` if provided.
69
69
  */
70
+ /**
71
+ * Generic SQL pagination helper that works with joins.
72
+ *
73
+ * @async
74
+ */
70
75
  export async function sqlPaginate({
71
76
  mapRow,
72
77
  orderBy,
@@ -76,24 +81,28 @@ export async function sqlPaginate({
76
81
  columns = '*',
77
82
  filter = {},
78
83
  snakeCase = true,
79
- distinctOn,
80
84
  }) {
81
- const listQuery = baseQuery.clone()
82
- const countQuery = baseQuery.clone()
83
-
84
85
  const applyFilterFn = snakeCase ? applyFilterSnakeCase : applyFilter
85
86
 
86
- applyFilterFn({ query: listQuery, filter })
87
- applyFilterFn({ query: countQuery, filter })
87
+ const filteredQuery = baseQuery.clone()
88
+
89
+ applyFilterFn({ query: filteredQuery, filter })
90
+
91
+ const listQuery = filteredQuery.clone()
92
+ const countQuery = filteredQuery.clone()
88
93
 
89
94
  applyOrderBy({ query: listQuery, orderBy })
90
95
  applyPagination({ query: listQuery, page, limit })
91
96
 
97
+ const wrappedCountQuery = baseQuery.client
98
+ .queryBuilder()
99
+ .count('* as count')
100
+ .from(countQuery.as('t'))
101
+ .first()
102
+
92
103
  const [rows, countResult] = await Promise.all([
93
104
  listQuery.select(columns),
94
- distinctOn
95
- ? countQuery.countDistinct(`${distinctOn} as count`).first()
96
- : countQuery.count('* as count').first(),
105
+ wrappedCountQuery,
97
106
  ])
98
107
 
99
108
  const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
@@ -39,6 +39,16 @@ export function startPostgres({
39
39
 
40
40
  stopPostgres(containerName)
41
41
 
42
+ // Kill any container that might still be holding the port
43
+ try {
44
+ const containerId = execSync(`docker ps -q --filter "publish=${port}"`, {
45
+ encoding: 'utf8',
46
+ }).trim()
47
+ if (containerId) {
48
+ execSync(`docker rm -f ${containerId}`, { stdio: 'ignore' })
49
+ }
50
+ } catch {}
51
+
42
52
  execSync(
43
53
  `docker run -d \
44
54
  --name ${containerName} \
@@ -35,6 +35,18 @@ export function startRabbit({ containerName, ...rest }) {
35
35
  execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
36
36
  } catch {}
37
37
 
38
+ // Kill any containers that might still be holding the ports
39
+ for (const port of [rest.amqpPort, rest.uiPort]) {
40
+ try {
41
+ const id = execSync(`docker ps -q --filter "publish=${port}"`, {
42
+ encoding: 'utf8',
43
+ }).trim()
44
+ if (id) {
45
+ execSync(`docker rm -f ${id}`, { stdio: 'ignore' })
46
+ }
47
+ } catch {}
48
+ }
49
+
38
50
  execSync(
39
51
  `docker run -d \
40
52
  --name ${containerName} \
@@ -42,6 +54,7 @@ export function startRabbit({ containerName, ...rest }) {
42
54
  -e RABBITMQ_DEFAULT_PASS=${rest.pass} \
43
55
  -p ${rest.amqpPort}:5672 \
44
56
  -p ${rest.uiPort}:15672 \
57
+ --tmpfs /var/lib/rabbitmq \
45
58
  --health-cmd="rabbitmq-diagnostics -q ping" \
46
59
  --health-interval=5s \
47
60
  --health-timeout=5s \
@@ -0,0 +1,141 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
+ import httpServer from 'node:http'
4
+ import { http } from '../../src/http/http.js'
5
+
6
+ let server
7
+ let baseUrl
8
+
9
+ function collectBody(req) {
10
+ return new Promise((resolve) => {
11
+ const chunks = []
12
+
13
+ req.on('data', (chunk) => {
14
+ chunks.push(chunk)
15
+ })
16
+
17
+ req.on('end', () => {
18
+ resolve(Buffer.concat(chunks).toString())
19
+ })
20
+ })
21
+ }
22
+
23
+ beforeAll(async () => {
24
+ server = httpServer.createServer(async (req, res) => {
25
+ const body = await collectBody(req)
26
+
27
+ res.setHeader('content-type', 'application/json')
28
+
29
+ res.end(
30
+ JSON.stringify({
31
+ method: req.method,
32
+ headers: req.headers,
33
+ body,
34
+ }),
35
+ )
36
+ })
37
+
38
+ await new Promise((resolve) => {
39
+ server.listen(0, () => {
40
+ const { port } = server.address()
41
+ baseUrl = `http://localhost:${port}`
42
+ resolve()
43
+ })
44
+ })
45
+ })
46
+
47
+ afterAll(async () => {
48
+ await new Promise((resolve) => server.close(resolve))
49
+ })
50
+
51
+ describe('http util integration', () => {
52
+ it('should send JSON body', async () => {
53
+ const result = await http.post({
54
+ url: `${baseUrl}/json`,
55
+ body: { hello: 'world' },
56
+ })
57
+
58
+ expect(result.method).toBe('POST')
59
+ expect(result.body).toBe(JSON.stringify({ hello: 'world' }))
60
+ })
61
+
62
+ it('should send string body without stringify', async () => {
63
+ const result = await http.post({
64
+ url: `${baseUrl}/string`,
65
+ body: 'plain-text',
66
+ })
67
+
68
+ expect(result.body).toBe('plain-text')
69
+ })
70
+
71
+ it('should support base64 string', async () => {
72
+ const base64 = Buffer.from('hello').toString('base64')
73
+
74
+ const result = await http.post({
75
+ url: `${baseUrl}/base64`,
76
+ body: base64,
77
+ })
78
+
79
+ expect(result.body).toBe(base64)
80
+ })
81
+
82
+ it('should support URLSearchParams', async () => {
83
+ const params = new URLSearchParams({
84
+ grant_type: 'client_credentials',
85
+ scope: 'read',
86
+ })
87
+
88
+ const result = await http.post({
89
+ url: `${baseUrl}/form`,
90
+ body: params,
91
+ })
92
+
93
+ expect(result.body).toBe(params.toString())
94
+ })
95
+
96
+ it('should support PUT with JSON', async () => {
97
+ const result = await http.put({
98
+ url: `${baseUrl}/put`,
99
+ body: { a: 1 },
100
+ })
101
+
102
+ expect(result.method).toBe('PUT')
103
+ expect(result.body).toBe(JSON.stringify({ a: 1 }))
104
+ })
105
+
106
+ it('should support PATCH with JSON', async () => {
107
+ const result = await http.patch({
108
+ url: `${baseUrl}/patch`,
109
+ body: { b: 2 },
110
+ })
111
+
112
+ expect(result.method).toBe('PATCH')
113
+ expect(result.body).toBe(JSON.stringify({ b: 2 }))
114
+ })
115
+
116
+ it('should support DELETE with body', async () => {
117
+ const result = await http.deleteApi({
118
+ url: `${baseUrl}/delete`,
119
+ body: { remove: true },
120
+ })
121
+
122
+ expect(result.method).toBe('DELETE')
123
+ expect(result.body).toBe(JSON.stringify({ remove: true }))
124
+ })
125
+
126
+ it('should support GET request', async () => {
127
+ const result = await http.get({
128
+ url: `${baseUrl}/get`,
129
+ })
130
+
131
+ expect(result.method).toBe('GET')
132
+ })
133
+
134
+ it('should support HEAD request', async () => {
135
+ const response = await http.head({
136
+ url: `${baseUrl}/head`,
137
+ })
138
+
139
+ expect(response.status).toBe(200)
140
+ })
141
+ })
@@ -34,21 +34,37 @@ beforeAll(async () => {
34
34
  table.uuid('id').primary()
35
35
  table.string('name').notNullable()
36
36
  table.string('type').notNullable()
37
- table.bigInteger('age').notNullable()
37
+ table.integer('age').notNullable()
38
38
  table.timestamp('created_at').notNullable()
39
39
  })
40
+
41
+ await db.schema.createTable('roles', (table) => {
42
+ table.increments('id').primary()
43
+ table.uuid('tenant_id').notNullable()
44
+ table.string('role_type').notNullable()
45
+ })
46
+
47
+ await db.schema.createTable('tenant_tags', (table) => {
48
+ table.increments('id').primary()
49
+ table.uuid('tenant_id').notNullable()
50
+ table.string('tag').notNullable()
51
+ })
40
52
  })
41
53
 
42
54
  afterAll(async () => {
43
55
  if (db) {
44
56
  await db.destroy()
45
57
  }
58
+
46
59
  stopPostgres(PG_OPTIONS.containerName)
47
60
  })
48
61
 
49
62
  beforeEach(async () => {
63
+ await db('tenant_tags').truncate()
64
+ await db('roles').truncate()
50
65
  await db('tenants').truncate()
51
- const records = [
66
+
67
+ await db('tenants').insert([
52
68
  {
53
69
  id: '00000000-0000-0000-0000-000000000001',
54
70
  name: 'Tenant A',
@@ -59,54 +75,61 @@ beforeEach(async () => {
59
75
  {
60
76
  id: '00000000-0000-0000-0000-000000000002',
61
77
  name: 'Tenant B',
62
- age: 3,
63
78
  type: 'business',
79
+ age: 3,
64
80
  created_at: new Date('2024-01-02'),
65
81
  },
66
82
  {
67
83
  id: '00000000-0000-0000-0000-000000000003',
68
84
  name: 'Tenant C',
69
- age: 1,
70
85
  type: 'cpa',
86
+ age: 1,
71
87
  created_at: new Date('2023-01-03'),
72
88
  },
73
-
74
89
  {
75
90
  id: '00000000-0000-0000-0000-000000000004',
76
91
  name: 'Tenant D',
77
- age: 7,
78
92
  type: 'cpa',
93
+ age: 7,
79
94
  created_at: new Date('2022-01-03'),
80
95
  },
81
-
82
96
  {
83
97
  id: '00000000-0000-0000-0000-000000000005',
84
98
  name: 'Tenant E',
85
- age: 0,
86
99
  type: 'cpa',
100
+ age: 0,
87
101
  created_at: new Date('2021-01-03'),
88
102
  },
89
- ]
90
- await db('tenants').insert(records)
91
- })
103
+ ])
92
104
 
93
- describe('paginate integration', () => {
94
- it('returns first page without ordering guarantees', async () => {
95
- const result = await sqlPaginate({
96
- baseQuery: db('tenants'),
97
- page: 2,
98
- limit: 2,
99
- })
105
+ await db('roles').insert([
106
+ {
107
+ tenant_id: '00000000-0000-0000-0000-000000000001',
108
+ role_type: 'customer',
109
+ },
110
+ {
111
+ tenant_id: '00000000-0000-0000-0000-000000000001',
112
+ role_type: 'supplier',
113
+ },
114
+ {
115
+ tenant_id: '00000000-0000-0000-0000-000000000002',
116
+ role_type: 'customer',
117
+ },
118
+ {
119
+ tenant_id: '00000000-0000-0000-0000-000000000003',
120
+ role_type: 'supplier',
121
+ },
122
+ ])
100
123
 
101
- expect(result.totalCount).toBe(5)
102
- expect(result.pages).toBe(3)
103
- expect(result.page).toBe(2)
104
- expect(result.hasPrevious).toBe(true)
105
- expect(result.hasNext).toBe(true)
106
- expect(result.list).toHaveLength(2)
107
- })
124
+ await db('tenant_tags').insert([
125
+ { tenant_id: '00000000-0000-0000-0000-000000000001', tag: 'vip' },
126
+ { tenant_id: '00000000-0000-0000-0000-000000000001', tag: 'active' },
127
+ { tenant_id: '00000000-0000-0000-0000-000000000002', tag: 'active' },
128
+ ])
129
+ })
108
130
 
109
- it('returns second page without ordering guarantees', async () => {
131
+ describe('sqlPaginate integration', () => {
132
+ it('basic pagination works', async () => {
110
133
  const result = await sqlPaginate({
111
134
  baseQuery: db('tenants'),
112
135
  page: 2,
@@ -116,29 +139,19 @@ describe('paginate integration', () => {
116
139
  expect(result.totalCount).toBe(5)
117
140
  expect(result.pages).toBe(3)
118
141
  expect(result.page).toBe(2)
119
- expect(result.hasPrevious).toBe(true)
120
- expect(result.hasNext).toBe(true)
121
142
  expect(result.list).toHaveLength(2)
122
143
  })
123
144
 
124
- it('applies filters correctly', async () => {
125
- const minDate = new Date('2024-01-01')
145
+ it('filters correctly', async () => {
126
146
  const result = await sqlPaginate({
127
147
  baseQuery: db('tenants'),
128
- filter: {
129
- createdAt: { lte: new Date(), gte: minDate },
130
- },
131
- limit: 10,
132
- page: 1,
148
+ filter: { type: 'business' },
133
149
  })
134
150
 
135
151
  expect(result.totalCount).toBe(2)
136
-
137
- const names = result.list.map((t) => t.name).sort()
138
- expect(names).toEqual(['Tenant A', 'Tenant B'])
139
152
  })
140
153
 
141
- it('supports custom ordering', async () => {
154
+ it('orders correctly', async () => {
142
155
  const result = await sqlPaginate({
143
156
  baseQuery: db('tenants'),
144
157
  orderBy: {
@@ -147,13 +160,7 @@ describe('paginate integration', () => {
147
160
  },
148
161
  })
149
162
 
150
- expect(result.list.map((t) => t.name)).toEqual([
151
- 'Tenant A',
152
- 'Tenant B',
153
- 'Tenant C',
154
- 'Tenant D',
155
- 'Tenant E',
156
- ])
163
+ expect(result.list[0].name).toBe('Tenant A')
157
164
  })
158
165
 
159
166
  it('supports row mapping', async () => {
@@ -165,20 +172,245 @@ describe('paginate integration', () => {
165
172
  }),
166
173
  })
167
174
 
168
- expect(result.list.length).toBeGreaterThan(0)
169
175
  expect(result.list[0].mapped).toBe(true)
170
176
  })
171
177
 
172
- it('returns empty list when no records match filter', async () => {
178
+ it('works with join', async () => {
179
+ const baseQuery = db('tenants').innerJoin(
180
+ 'roles',
181
+ 'roles.tenant_id',
182
+ 'tenants.id',
183
+ )
184
+
185
+ const result = await sqlPaginate({
186
+ baseQuery,
187
+ limit: 10,
188
+ })
189
+
190
+ expect(result.totalCount).toBeGreaterThan(0)
191
+ })
192
+
193
+ it('filters on joined table', async () => {
194
+ // Joined-table column filters are applied directly to baseQuery,
195
+ // not through the filter param (which is scoped to the base table).
196
+ const baseQuery = db('tenants')
197
+ .innerJoin('roles', 'roles.tenant_id', 'tenants.id')
198
+ .where('roles.role_type', 'customer')
199
+
200
+ const result = await sqlPaginate({
201
+ baseQuery,
202
+ limit: 10,
203
+ })
204
+
205
+ const names = result.list.map((t) => t.name).sort()
206
+
207
+ expect(names).toEqual(['Tenant A', 'Tenant B'])
208
+ })
209
+
210
+ it('handles duplicate rows from join', async () => {
211
+ const baseQuery = db('tenants').innerJoin(
212
+ 'tenant_tags',
213
+ 'tenant_tags.tenant_id',
214
+ 'tenants.id',
215
+ )
216
+
217
+ const result = await sqlPaginate({
218
+ baseQuery,
219
+ limit: 10,
220
+ })
221
+
222
+ expect(result.totalCount).toBeGreaterThan(0)
223
+ })
224
+
225
+ it('paginates correctly with join', async () => {
226
+ const baseQuery = db('tenants').innerJoin(
227
+ 'roles',
228
+ 'roles.tenant_id',
229
+ 'tenants.id',
230
+ )
231
+
232
+ const result = await sqlPaginate({
233
+ baseQuery,
234
+ limit: 1,
235
+ page: 2,
236
+ })
237
+
238
+ expect(result.page).toBe(2)
239
+ expect(result.list.length).toBe(1)
240
+ })
241
+
242
+ it('orders correctly with join', async () => {
243
+ const baseQuery = db('tenants').innerJoin(
244
+ 'roles',
245
+ 'roles.tenant_id',
246
+ 'tenants.id',
247
+ )
248
+
249
+ const result = await sqlPaginate({
250
+ baseQuery,
251
+ orderBy: {
252
+ column: 'tenants.name',
253
+ direction: 'asc',
254
+ },
255
+ })
256
+
257
+ expect(result.list[0].name).toBe('Tenant A')
258
+ })
259
+
260
+ it('works with double join (3 tables)', async () => {
261
+ const baseQuery = db('tenants')
262
+ .innerJoin('roles', 'roles.tenant_id', 'tenants.id')
263
+ .innerJoin('tenant_tags', 'tenant_tags.tenant_id', 'tenants.id')
264
+
265
+ const result = await sqlPaginate({
266
+ baseQuery,
267
+ limit: 10,
268
+ })
269
+
270
+ expect(result.totalCount).toBeGreaterThan(0)
271
+ })
272
+
273
+ it('returns empty result correctly', async () => {
173
274
  const result = await sqlPaginate({
174
275
  baseQuery: db('tenants'),
175
- filter: { type: 'non-existing' },
276
+ filter: { name: 'does-not-exist' },
176
277
  })
177
278
 
178
279
  expect(result.totalCount).toBe(0)
179
- expect(result.pages).toBe(0)
180
280
  expect(result.list).toEqual([])
181
- expect(result.hasNext).toBe(false)
182
- expect(result.hasPrevious).toBe(false)
281
+ expect(result.pages).toBe(0)
282
+ })
283
+
284
+ it('exact count is correct when join produces duplicate rows', async () => {
285
+ // tenant A has 2 tags, tenant B has 1 tag → join produces 3 rows total
286
+ const baseQuery = db('tenants').innerJoin(
287
+ 'tenant_tags',
288
+ 'tenant_tags.tenant_id',
289
+ 'tenants.id',
290
+ )
291
+
292
+ const result = await sqlPaginate({
293
+ baseQuery,
294
+ limit: 10,
295
+ })
296
+
297
+ expect(result.totalCount).toBe(3)
298
+ })
299
+
300
+ it('orders correctly with plain column on join (auto-prefix)', async () => {
301
+ // plain 'name' should be auto-prefixed to 'tenants.name' to avoid ambiguity
302
+ const baseQuery = db('tenants').innerJoin(
303
+ 'roles',
304
+ 'roles.tenant_id',
305
+ 'tenants.id',
306
+ )
307
+
308
+ const result = await sqlPaginate({
309
+ baseQuery,
310
+ orderBy: { column: 'name', direction: 'asc' },
311
+ })
312
+
313
+ expect(result.list[0].name).toBe('Tenant A')
314
+ })
315
+
316
+ it('orders correctly with multiple columns (array)', async () => {
317
+ // sort by type asc, then name desc → business: B, A then cpa: E, D, C
318
+ const result = await sqlPaginate({
319
+ baseQuery: db('tenants'),
320
+ orderBy: [
321
+ { column: 'type', direction: 'asc' },
322
+ { column: 'name', direction: 'desc' },
323
+ ],
324
+ })
325
+
326
+ expect(result.list[0].name).toBe('Tenant B')
327
+ expect(result.list[1].name).toBe('Tenant A')
328
+ expect(result.list[4].name).toBe('Tenant C')
329
+ })
330
+
331
+ it('orders correctly with SQL expression (skips auto-prefix)', async () => {
332
+ // lower(name) is a SQL expression — must not be prefixed
333
+ const result = await sqlPaginate({
334
+ baseQuery: db('tenants'),
335
+ orderBy: { column: 'lower(name)', direction: 'asc' },
336
+ })
337
+
338
+ expect(result.list[0].name).toBe('Tenant A')
339
+ expect(result.list[4].name).toBe('Tenant E')
340
+ })
341
+
342
+ it('returns only requested columns', async () => {
343
+ const baseQuery = db('tenants').innerJoin(
344
+ 'roles',
345
+ 'roles.tenant_id',
346
+ 'tenants.id',
347
+ )
348
+
349
+ // columns must be an array — a comma-separated string is not valid for Knex select
350
+ const result = await sqlPaginate({
351
+ baseQuery,
352
+ columns: ['tenants.name', 'tenants.type'],
353
+ limit: 10,
354
+ })
355
+
356
+ const row = result.list[0]
357
+
358
+ expect(row).toHaveProperty('name')
359
+ expect(row).toHaveProperty('type')
360
+ expect(row).not.toHaveProperty('id')
361
+ expect(row).not.toHaveProperty('role_type')
362
+ })
363
+
364
+ it('hasPrevious and hasNext are correct', async () => {
365
+ // 5 tenants, limit 2 → 3 pages
366
+ const page1 = await sqlPaginate({
367
+ baseQuery: db('tenants'),
368
+ page: 1,
369
+ limit: 2,
370
+ })
371
+
372
+ expect(page1.hasPrevious).toBe(false)
373
+ expect(page1.hasNext).toBe(true)
374
+
375
+ const page2 = await sqlPaginate({
376
+ baseQuery: db('tenants'),
377
+ page: 2,
378
+ limit: 2,
379
+ })
380
+
381
+ expect(page2.hasPrevious).toBe(true)
382
+ expect(page2.hasNext).toBe(true)
383
+
384
+ const page3 = await sqlPaginate({
385
+ baseQuery: db('tenants'),
386
+ page: 3,
387
+ limit: 2,
388
+ })
389
+
390
+ expect(page3.hasPrevious).toBe(true)
391
+ expect(page3.hasNext).toBe(false)
392
+ })
393
+
394
+ it('filters with multiple conditions', async () => {
395
+ const result = await sqlPaginate({
396
+ baseQuery: db('tenants'),
397
+ filter: { type: 'cpa', name: 'Tenant C' },
398
+ })
399
+
400
+ expect(result.totalCount).toBe(1)
401
+ expect(result.list[0].name).toBe('Tenant C')
402
+ })
403
+
404
+ it('filters with array values (IN operator)', async () => {
405
+ const result = await sqlPaginate({
406
+ baseQuery: db('tenants'),
407
+ filter: { name: ['Tenant A', 'Tenant C'] },
408
+ })
409
+
410
+ expect(result.totalCount).toBe(2)
411
+
412
+ const names = result.list.map((r) => r.name).sort()
413
+
414
+ expect(names).toEqual(['Tenant A', 'Tenant C'])
183
415
  })
184
416
  })
@@ -20,7 +20,7 @@ const log = pino({
20
20
  level: 'silent',
21
21
  })
22
22
  // @ts-ignore
23
- async function waitForRabbitConnection({ uri, log, timeoutMs = 10000 }) {
23
+ async function waitForRabbitConnection({ uri, log, timeoutMs = 30000 }) {
24
24
  const start = Date.now()
25
25
 
26
26
  while (Date.now() - start < timeoutMs) {
@@ -11,11 +11,11 @@ import {
11
11
  import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
12
12
 
13
13
  const PG_OPTIONS = {
14
- port: 5445,
14
+ port: 5449,
15
15
  db: 'testdb',
16
16
  user: 'testuser',
17
17
  pass: 'testpass',
18
- containerName: 'postgres-base-repo-test-5445',
18
+ containerName: 'postgres-base-repo-test-5449',
19
19
  }
20
20
 
21
21
  const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
@@ -11,11 +11,11 @@ import {
11
11
  import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
12
12
 
13
13
  const PG_OPTIONS = {
14
- port: 5446,
14
+ port: 5448,
15
15
  db: 'testdb',
16
16
  user: 'testuser',
17
17
  pass: 'testpass',
18
- containerName: 'postgres-tenant-repo-test-5446',
18
+ containerName: 'postgres-tenant-repo-test-5448',
19
19
  }
20
20
 
21
21
  const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
@@ -45,8 +45,9 @@ export function head({
45
45
  * put: (options: HttpPutOptions) => Promise<any>,
46
46
  * post: (options: HttpPostOptions) => Promise<any>,
47
47
  * patch: (options: HttpPatchOptions) => Promise<any>,
48
- * deleteApi: (options: HttpDeleteOptions) => Promise<any>,
49
- * head: (options: HttpHeadOptions) => Promise<Response>
48
+ * head: (options: HttpHeadOptions) => Promise<Response>,
49
+ * deleteApi: (options: HttpDeleteOptions) => Promise<any>
50
+ *
50
51
  * }}
51
52
  */
52
53
  export const http: {
@@ -54,8 +55,8 @@ export const http: {
54
55
  put: (options: HttpPutOptions) => Promise<any>
55
56
  post: (options: HttpPostOptions) => Promise<any>
56
57
  patch: (options: HttpPatchOptions) => Promise<any>
57
- deleteApi: (options: HttpDeleteOptions) => Promise<any>
58
58
  head: (options: HttpHeadOptions) => Promise<Response>
59
+ deleteApi: (options: HttpDeleteOptions) => Promise<any>
59
60
  }
60
61
  export type ResponseTypeValue = 'json' | 'xml' | 'text' | 'raw' | 'file'
61
62
  export type HttpGetOptions = {
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * Applies ORDER BY clause(s) to a Knex query builder.
3
3
  *
4
- * Supports a single orderBy object or an array of orderBy objects.
5
- * Validates order direction to prevent invalid SQL.
4
+ * Supports single or multiple order definitions.
5
+ *
6
+ * Column handling:
7
+ * - SQL expressions (e.g. "lower(name)", "count(*)") → orderByRaw, never quoted
8
+ * - Already-qualified columns (e.g. "tenants.name") → orderBy as-is
9
+ * - Plain column names (e.g. "name") → auto-prefixed with base table name
10
+ * to avoid ambiguity in joined queries, matching applyFilter behaviour
6
11
  *
7
12
  * @param {Object} params
8
- * @param {import('knex').Knex.QueryBuilder} params.query - Knex query builder instance
9
- * @param {OrderByItem | OrderByItem[]} params.orderBy - Order by definition(s)
10
- * @returns {import('knex').Knex.QueryBuilder} The modified query builder
13
+ * @param {import('knex').Knex.QueryBuilder} params.query
14
+ * @param {OrderByItem | OrderByItem[]} params.orderBy
15
+ * @returns {import('knex').Knex.QueryBuilder}
11
16
  */
12
17
  export function applyOrderBy({
13
18
  query,
@@ -17,12 +22,6 @@ export function applyOrderBy({
17
22
  orderBy: OrderByItem | OrderByItem[]
18
23
  }): import('knex').Knex.QueryBuilder
19
24
  export type OrderByItem = {
20
- /**
21
- * - Column name to order by
22
- */
23
25
  column: string
24
- /**
25
- * - Order direction
26
- */
27
26
  direction?: 'asc' | 'desc'
28
27
  }
@@ -61,6 +61,11 @@
61
61
  * The list of records for the current page.
62
62
  * Rows are mapped using `mapRow` if provided.
63
63
  */
64
+ /**
65
+ * Generic SQL pagination helper that works with joins.
66
+ *
67
+ * @async
68
+ */
64
69
  export function sqlPaginate({
65
70
  mapRow,
66
71
  orderBy,
@@ -70,13 +75,20 @@ export function sqlPaginate({
70
75
  columns,
71
76
  filter,
72
77
  snakeCase,
73
- distinctOn,
74
78
  }: {
75
- baseQuery: import('knex').Knex.QueryBuilder
76
- filter?: any
77
- snakeCase?: boolean
78
- orderBy?: any | any[]
79
+ mapRow: any
80
+ orderBy: any
79
81
  page?: number
82
+ baseQuery: any
80
83
  limit?: number
81
- mapRow?: Function
82
- }): Promise<any>
84
+ columns?: string
85
+ filter?: {}
86
+ snakeCase?: boolean
87
+ }): Promise<{
88
+ page: number
89
+ pages: number
90
+ totalCount: number
91
+ hasPrevious: boolean
92
+ hasNext: boolean
93
+ list: any
94
+ }>
@@ -49,5 +49,12 @@ export class BaseRepository {
49
49
  column: string
50
50
  direction: string
51
51
  }
52
- }): Promise<any>
52
+ }): Promise<{
53
+ page: number
54
+ pages: number
55
+ totalCount: number
56
+ hasPrevious: boolean
57
+ hasNext: boolean
58
+ list: any
59
+ }>
53
60
  }
@@ -6,6 +6,13 @@ export class TenantScopedRepository extends BaseRepository {
6
6
  /**
7
7
  * Overrides find to enforce tenant presence
8
8
  */
9
- find(params?: {}): Promise<any>
9
+ find(params?: {}): Promise<{
10
+ page: number
11
+ pages: number
12
+ totalCount: number
13
+ hasPrevious: boolean
14
+ hasNext: boolean
15
+ list: any
16
+ }>
10
17
  }
11
18
  import { BaseRepository } from './BaseRepository.js'
package/vitest.config.js CHANGED
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'
3
3
  export default defineConfig({
4
4
  test: {
5
5
  testTimeout: 30000,
6
- hookTimeout: 30000,
6
+ hookTimeout: 90000,
7
7
  exclude: [
8
8
  'node_modules',
9
9
  'types/**',