core-services-sdk 1.3.84 → 1.3.85

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.84",
3
+ "version": "1.3.85",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -73,6 +73,7 @@ export async function sqlPaginate({
73
73
  page = 1,
74
74
  baseQuery,
75
75
  limit = 10,
76
+ columns = '*',
76
77
  filter = {},
77
78
  snakeCase = true,
78
79
  distinctOn,
@@ -89,7 +90,7 @@ export async function sqlPaginate({
89
90
  applyPagination({ query: listQuery, page, limit })
90
91
 
91
92
  const [rows, countResult] = await Promise.all([
92
- listQuery.select('*'),
93
+ listQuery.select(columns),
93
94
  distinctOn
94
95
  ? countQuery.countDistinct(`${distinctOn} as count`).first()
95
96
  : countQuery.count('* as count').first(),
@@ -14,13 +14,14 @@ import { toSnakeCase } from '../../core/case-mapper.js'
14
14
  */
15
15
 
16
16
  export class BaseRepository {
17
- constructor({ db, log, baseQueryBuilder } = {}) {
17
+ constructor({ db, log, columns, baseQueryBuilder } = {}) {
18
18
  if (!db) {
19
19
  throw new Error('BaseRepository requires db instance')
20
20
  }
21
21
 
22
22
  this.db = db
23
23
  this.log = log
24
+ this._columns = columns ?? '*'
24
25
  this._baseQueryBuilder = baseQueryBuilder
25
26
  }
26
27
 
@@ -48,7 +49,7 @@ export class BaseRepository {
48
49
 
49
50
  if (this._baseQueryBuilder) {
50
51
  // Pass full params for optional dynamic shaping
51
- return this._baseQueryBuilder(qb, params)
52
+ this._baseQueryBuilder(qb, params)
52
53
  }
53
54
 
54
55
  return qb
@@ -59,11 +60,12 @@ export class BaseRepository {
59
60
  */
60
61
  async find({
61
62
  page = 1,
63
+ columns,
62
64
  limit = 10,
63
65
  filter = {},
64
- orderBy = { column: 'created_at', direction: 'desc' },
65
66
  options = {},
66
67
  mapRow = (row) => row,
68
+ orderBy = { column: 'created_at', direction: 'desc' },
67
69
  ...restParams
68
70
  }) {
69
71
  try {
@@ -79,10 +81,11 @@ export class BaseRepository {
79
81
  return await sqlPaginate({
80
82
  page,
81
83
  limit,
84
+ mapRow,
82
85
  orderBy,
83
86
  baseQuery: qb,
84
- mapRow,
85
87
  filter: toSnakeCase(filter),
88
+ columns: columns ?? this._columns,
86
89
  })
87
90
  } catch (error) {
88
91
  if (this.log) {
@@ -0,0 +1,172 @@
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 { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
12
+
13
+ const PG_OPTIONS = {
14
+ port: 5445,
15
+ db: 'testdb',
16
+ user: 'testuser',
17
+ pass: 'testpass',
18
+ containerName: 'postgres-base-repo-test-5445',
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('customers', (table) => {
34
+ table.uuid('id').primary()
35
+ table.string('name').notNullable()
36
+ table.timestamp('created_at').notNullable()
37
+ })
38
+
39
+ await db.schema.createTable('orders', (table) => {
40
+ table.uuid('id').primary()
41
+ table.uuid('customer_id').notNullable()
42
+ table.decimal('amount', 10, 2)
43
+ table.timestamp('created_at').notNullable()
44
+ })
45
+ })
46
+
47
+ afterAll(async () => {
48
+ if (db) {
49
+ await db.destroy()
50
+ }
51
+
52
+ stopPostgres(PG_OPTIONS.containerName)
53
+ })
54
+
55
+ beforeEach(async () => {
56
+ await db('orders').truncate()
57
+ await db('customers').truncate()
58
+
59
+ await db('customers').insert([
60
+ {
61
+ id: '00000000-0000-0000-0000-000000000001',
62
+ name: 'Alice',
63
+ created_at: new Date('2024-01-01'),
64
+ },
65
+ {
66
+ id: '00000000-0000-0000-0000-000000000002',
67
+ name: 'Bob',
68
+ created_at: new Date('2024-01-02'),
69
+ },
70
+ ])
71
+
72
+ await db('orders').insert([
73
+ {
74
+ id: '00000000-0000-0000-0000-000000000101',
75
+ customer_id: '00000000-0000-0000-0000-000000000001',
76
+ amount: 100,
77
+ created_at: new Date('2024-01-10'),
78
+ },
79
+ {
80
+ id: '00000000-0000-0000-0000-000000000102',
81
+ customer_id: '00000000-0000-0000-0000-000000000001',
82
+ amount: 200,
83
+ created_at: new Date('2024-01-11'),
84
+ },
85
+ {
86
+ id: '00000000-0000-0000-0000-000000000103',
87
+ customer_id: '00000000-0000-0000-0000-000000000002',
88
+ amount: 300,
89
+ created_at: new Date('2024-01-12'),
90
+ },
91
+ ])
92
+ })
93
+
94
+ class OrdersRepository extends BaseRepository {
95
+ static tableName = 'orders'
96
+
97
+ constructor(deps) {
98
+ super({
99
+ ...deps,
100
+ baseQueryBuilder(qb) {
101
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
102
+ },
103
+ })
104
+ }
105
+ }
106
+
107
+ describe('BaseRepository integration', () => {
108
+ it('baseQueryBuilder applies JOIN correctly', async () => {
109
+ const repo = new OrdersRepository({ db })
110
+
111
+ const qb = repo.baseQuery()
112
+
113
+ const rows = await qb.select('orders.id', 'customers.name')
114
+
115
+ expect(rows).toHaveLength(3)
116
+ expect(rows[0]).toHaveProperty('name')
117
+ })
118
+
119
+ it('find returns joined data using columns', async () => {
120
+ const repo = new OrdersRepository({ db })
121
+
122
+ const result = await repo.find({
123
+ columns: ['orders.id', 'orders.amount', 'customers.name'],
124
+ limit: 10,
125
+ })
126
+
127
+ expect(result.list.length).toBe(3)
128
+
129
+ const row = result.list[0]
130
+
131
+ expect(row).toHaveProperty('amount')
132
+ expect(row).toHaveProperty('name')
133
+ })
134
+
135
+ it('pagination works with joins', async () => {
136
+ const repo = new OrdersRepository({ db })
137
+
138
+ const result = await repo.find({
139
+ columns: ['orders.id', 'customers.name'],
140
+ limit: 2,
141
+ page: 1,
142
+ })
143
+
144
+ expect(result.list.length).toBe(2)
145
+ expect(result.totalCount).toBe(3)
146
+ expect(result.pages).toBe(2)
147
+ })
148
+
149
+ it('constructor default columns are used', async () => {
150
+ class RepoWithColumns extends BaseRepository {
151
+ static tableName = 'orders'
152
+
153
+ constructor(deps) {
154
+ super({
155
+ ...deps,
156
+ columns: ['orders.id'],
157
+ baseQueryBuilder(qb) {
158
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
159
+ },
160
+ })
161
+ }
162
+ }
163
+
164
+ const repo = new RepoWithColumns({ db })
165
+
166
+ const result = await repo.find({})
167
+
168
+ const row = result.list[0]
169
+
170
+ expect(Object.keys(row)).toEqual(['id'])
171
+ })
172
+ })
@@ -1,21 +1,14 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
3
3
 
4
- // 🔥 mock dependencies
4
+ // mock paginate
5
5
  vi.mock('../../src/postgresql/pagination/paginate.js', () => ({
6
- sqlPaginate: vi.fn().mockResolvedValue({ page: 1 }),
6
+ sqlPaginate: vi.fn(),
7
7
  }))
8
8
 
9
- vi.mock('../../filters/apply-filter-snake-case.js', () => ({
10
- applyFilterSnakeCase: vi.fn(),
11
- }))
12
-
13
- vi.mock('../../filters/apply-filter.js', () => ({
14
- applyFilter: vi.fn(),
15
- }))
16
-
17
- vi.mock('../../core/normalize-premitives-types-or-default.js', () => ({
18
- normalizeNumberOrDefault: vi.fn((v) => Number(v)),
9
+ // mock snakeCase mapper
10
+ vi.mock('../../src/core/case-mapper.js', () => ({
11
+ toSnakeCase: vi.fn((v) => v),
19
12
  }))
20
13
 
21
14
  // import AFTER mocks
@@ -24,6 +17,7 @@ import { toSnakeCase } from '../../src/core/case-mapper.js'
24
17
 
25
18
  describe('BaseRepository', () => {
26
19
  let dbMock
20
+ let qbMock
27
21
  let logMock
28
22
 
29
23
  class TestRepo extends BaseRepository {
@@ -31,13 +25,21 @@ describe('BaseRepository', () => {
31
25
  }
32
26
 
33
27
  beforeEach(() => {
34
- dbMock = vi.fn(() => ({
35
- clone: vi.fn(),
36
- }))
28
+ qbMock = {
29
+ clone: vi.fn().mockReturnThis(),
30
+ }
31
+
32
+ dbMock = vi.fn(() => qbMock)
37
33
 
38
34
  logMock = {
39
35
  error: vi.fn(),
40
36
  }
37
+
38
+ sqlPaginate.mockResolvedValue({ page: 1 })
39
+ })
40
+
41
+ afterEach(() => {
42
+ vi.clearAllMocks()
41
43
  })
42
44
 
43
45
  it('throws if db not provided', () => {
@@ -58,7 +60,7 @@ describe('BaseRepository', () => {
58
60
  })
59
61
 
60
62
  it('baseQuery uses trx when provided', () => {
61
- const trxMock = vi.fn()
63
+ const trxMock = vi.fn(() => qbMock)
62
64
  const repo = new TestRepo({ db: dbMock })
63
65
 
64
66
  repo.baseQuery({ trx: trxMock })
@@ -68,7 +70,8 @@ describe('BaseRepository', () => {
68
70
  })
69
71
 
70
72
  it('baseQuery applies baseQueryBuilder', () => {
71
- const builderMock = vi.fn((qb) => qb)
73
+ const builderMock = vi.fn()
74
+
72
75
  const repo = new TestRepo({
73
76
  db: dbMock,
74
77
  baseQueryBuilder: builderMock,
@@ -76,7 +79,7 @@ describe('BaseRepository', () => {
76
79
 
77
80
  repo.baseQuery({}, { some: 'param' })
78
81
 
79
- expect(builderMock).toHaveBeenCalled()
82
+ expect(builderMock).toHaveBeenCalledWith(qbMock, { some: 'param' })
80
83
  })
81
84
 
82
85
  it('find calls sqlPaginate with correct params', async () => {
@@ -88,17 +91,65 @@ describe('BaseRepository', () => {
88
91
  filter: { a: 1 },
89
92
  })
90
93
 
91
- expect(sqlPaginate).toHaveBeenCalled()
94
+ expect(toSnakeCase).toHaveBeenCalledWith({ a: 1 })
95
+
96
+ expect(sqlPaginate).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ page: 2,
99
+ limit: 5,
100
+ baseQuery: qbMock,
101
+ }),
102
+ )
92
103
  })
93
104
 
94
105
  it('logs error if sqlPaginate throws', async () => {
95
- // @ts-ignore
96
106
  sqlPaginate.mockRejectedValueOnce(new Error('fail'))
97
107
 
98
108
  const repo = new TestRepo({ db: dbMock, log: logMock })
99
109
 
100
- await expect(repo.find({ filter: {} })).rejects.toThrow()
110
+ await expect(repo.find({ filter: {} })).rejects.toThrow('fail')
101
111
 
102
112
  expect(logMock.error).toHaveBeenCalled()
103
113
  })
114
+
115
+ it('find uses columns passed to find()', async () => {
116
+ const repo = new TestRepo({ db: dbMock })
117
+
118
+ await repo.find({
119
+ columns: ['id', 'name'],
120
+ })
121
+
122
+ expect(sqlPaginate).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ columns: ['id', 'name'],
125
+ }),
126
+ )
127
+ })
128
+
129
+ it('find falls back to constructor columns', async () => {
130
+ const repo = new TestRepo({
131
+ db: dbMock,
132
+ columns: ['id', 'email'],
133
+ })
134
+
135
+ await repo.find({})
136
+
137
+ expect(sqlPaginate).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ columns: ['id', 'email'],
140
+ }),
141
+ )
142
+ })
143
+
144
+ it('find falls back to "*" when no columns provided', async () => {
145
+ const repo = new TestRepo({ db: dbMock })
146
+
147
+ await repo.find({})
148
+
149
+ expect(sqlPaginate).toHaveBeenCalledWith(
150
+ expect.objectContaining({
151
+ columns: '*',
152
+ }),
153
+ )
154
+ })
104
155
  })
@@ -0,0 +1,173 @@
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 { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
12
+
13
+ const PG_OPTIONS = {
14
+ port: 5446,
15
+ db: 'testdb',
16
+ user: 'testuser',
17
+ pass: 'testpass',
18
+ containerName: 'postgres-tenant-repo-test-5446',
19
+ }
20
+
21
+ const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
22
+
23
+ const TENANT_A = '00000000-0000-0000-0000-000000000010'
24
+ const TENANT_B = '00000000-0000-0000-0000-000000000020'
25
+
26
+ let db
27
+
28
+ beforeAll(async () => {
29
+ startPostgres(PG_OPTIONS)
30
+
31
+ db = knex({
32
+ client: 'pg',
33
+ connection: DATABASE_URI,
34
+ })
35
+
36
+ await db.schema.createTable('customers', (table) => {
37
+ table.uuid('id').primary()
38
+ table.uuid('tenant_id').notNullable()
39
+ table.string('name')
40
+ table.timestamp('created_at').notNullable()
41
+ })
42
+
43
+ await db.schema.createTable('orders', (table) => {
44
+ table.uuid('id').primary()
45
+ table.uuid('tenant_id').notNullable()
46
+ table.uuid('customer_id')
47
+ table.decimal('amount', 10, 2)
48
+ table.timestamp('created_at').notNullable()
49
+ })
50
+ })
51
+
52
+ afterAll(async () => {
53
+ if (db) {
54
+ await db.destroy()
55
+ }
56
+
57
+ stopPostgres(PG_OPTIONS.containerName)
58
+ })
59
+
60
+ beforeEach(async () => {
61
+ await db('orders').truncate()
62
+ await db('customers').truncate()
63
+
64
+ await db('customers').insert([
65
+ {
66
+ id: '00000000-0000-0000-0000-000000000001',
67
+ tenant_id: TENANT_A,
68
+ name: 'Alice',
69
+ created_at: new Date('2024-01-01'),
70
+ },
71
+ {
72
+ id: '00000000-0000-0000-0000-000000000002',
73
+ tenant_id: TENANT_B,
74
+ name: 'Bob',
75
+ created_at: new Date('2024-01-02'),
76
+ },
77
+ ])
78
+
79
+ await db('orders').insert([
80
+ {
81
+ id: '00000000-0000-0000-0000-000000000101',
82
+ tenant_id: TENANT_A,
83
+ customer_id: '00000000-0000-0000-0000-000000000001',
84
+ amount: 100,
85
+ created_at: new Date('2024-01-10'),
86
+ },
87
+ {
88
+ id: '00000000-0000-0000-0000-000000000102',
89
+ tenant_id: TENANT_A,
90
+ customer_id: '00000000-0000-0000-0000-000000000001',
91
+ amount: 200,
92
+ created_at: new Date('2024-01-11'),
93
+ },
94
+ {
95
+ id: '00000000-0000-0000-0000-000000000103',
96
+ tenant_id: TENANT_B,
97
+ customer_id: '00000000-0000-0000-0000-000000000002',
98
+ amount: 300,
99
+ created_at: new Date('2024-01-12'),
100
+ },
101
+ ])
102
+ })
103
+
104
+ class OrdersRepository extends TenantScopedRepository {
105
+ static tableName = 'orders'
106
+
107
+ constructor(deps) {
108
+ super({
109
+ ...deps,
110
+ baseQueryBuilder(qb) {
111
+ qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
112
+ },
113
+ })
114
+ }
115
+ }
116
+
117
+ describe('TenantScopedRepository integration', () => {
118
+ it('throws if tenantId missing', async () => {
119
+ const repo = new OrdersRepository({ db })
120
+
121
+ await expect(
122
+ repo.find({
123
+ filter: {},
124
+ }),
125
+ ).rejects.toThrow('tenantId is required')
126
+ })
127
+
128
+ it('returns only rows for tenant', async () => {
129
+ const repo = new OrdersRepository({ db })
130
+
131
+ const result = await repo.find({
132
+ filter: {
133
+ tenantId: TENANT_A,
134
+ },
135
+ columns: ['orders.id', 'orders.amount'],
136
+ })
137
+
138
+ expect(result.list.length).toBe(2)
139
+ })
140
+
141
+ it('works with join and columns', async () => {
142
+ const repo = new OrdersRepository({ db })
143
+
144
+ const result = await repo.find({
145
+ filter: {
146
+ tenantId: TENANT_A,
147
+ },
148
+ columns: ['orders.id', 'orders.amount', 'customers.name'],
149
+ })
150
+
151
+ const row = result.list[0]
152
+
153
+ expect(row).toHaveProperty('amount')
154
+ expect(row).toHaveProperty('name')
155
+ })
156
+
157
+ it('pagination respects tenant scope', async () => {
158
+ const repo = new OrdersRepository({ db })
159
+
160
+ const result = await repo.find({
161
+ filter: {
162
+ tenantId: TENANT_A,
163
+ },
164
+ limit: 1,
165
+ page: 1,
166
+ columns: ['orders.id'],
167
+ })
168
+
169
+ expect(result.list.length).toBe(1)
170
+ expect(result.totalCount).toBe(2)
171
+ expect(result.pages).toBe(2)
172
+ })
173
+ })
@@ -67,6 +67,7 @@ export function sqlPaginate({
67
67
  page,
68
68
  baseQuery,
69
69
  limit,
70
+ columns,
70
71
  filter,
71
72
  snakeCase,
72
73
  distinctOn,
@@ -10,9 +10,10 @@
10
10
  * Does NOT enforce tenant isolation.
11
11
  */
12
12
  export class BaseRepository {
13
- constructor({ db, log, baseQueryBuilder }?: {})
13
+ constructor({ db, log, columns, baseQueryBuilder }?: {})
14
14
  db: any
15
15
  log: any
16
+ _columns: any
16
17
  _baseQueryBuilder: any
17
18
  /**
18
19
  * Each concrete repository must define:
@@ -29,22 +30,24 @@ export class BaseRepository {
29
30
  */
30
31
  find({
31
32
  page,
33
+ columns,
32
34
  limit,
33
35
  filter,
34
- orderBy,
35
36
  options,
36
37
  mapRow,
38
+ orderBy,
37
39
  ...restParams
38
40
  }: {
39
41
  [x: string]: any
40
42
  page?: number
43
+ columns: any
41
44
  limit?: number
42
45
  filter?: {}
46
+ options?: {}
47
+ mapRow?: (row: any) => any
43
48
  orderBy?: {
44
49
  column: string
45
50
  direction: string
46
51
  }
47
- options?: {}
48
- mapRow?: (row: any) => any
49
52
  }): Promise<any>
50
53
  }