core-services-sdk 1.3.81 → 1.3.82

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.81",
3
+ "version": "1.3.82",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -5,4 +5,6 @@ export * from './filters/apply-filter.js'
5
5
  export * from './modifiers/apply-order-by.js'
6
6
  export * from './start-stop-postgres-docker.js'
7
7
  export * from './modifiers/apply-pagination.js'
8
+ export * from './repositories/BaseRepository.js'
8
9
  export * from './filters/apply-filter-snake-case.js'
10
+ export * from './repositories/TenantScopedRepository.js'
@@ -75,6 +75,7 @@ export async function sqlPaginate({
75
75
  limit = 10,
76
76
  filter = {},
77
77
  snakeCase = true,
78
+ distinctOn,
78
79
  }) {
79
80
  const listQuery = baseQuery.clone()
80
81
  const countQuery = baseQuery.clone()
@@ -89,7 +90,9 @@ export async function sqlPaginate({
89
90
 
90
91
  const [rows, countResult] = await Promise.all([
91
92
  listQuery.select('*'),
92
- countQuery.count('* as count').first(),
93
+ distinctOn
94
+ ? countQuery.countDistinct(`${distinctOn} as count`).first()
95
+ : countQuery.count('* as count').first(),
93
96
  ])
94
97
 
95
98
  const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
@@ -0,0 +1,95 @@
1
+ import { sqlPaginate } from '../pagination/paginate.js'
2
+ import { toSnakeCase } from '../../core/case-mapper.js'
3
+
4
+ /**
5
+ * BaseRepository
6
+ *
7
+ * Provides:
8
+ * - tableName getter (static + instance support)
9
+ * - baseQuery(options)
10
+ * - constructor-level query shaping (optional)
11
+ * - generic find() with pagination
12
+ *
13
+ * Does NOT enforce tenant isolation.
14
+ */
15
+
16
+ export class BaseRepository {
17
+ constructor({ db, log, baseQueryBuilder } = {}) {
18
+ if (!db) {
19
+ throw new Error('BaseRepository requires db instance')
20
+ }
21
+
22
+ this.db = db
23
+ this.log = log
24
+ this._baseQueryBuilder = baseQueryBuilder
25
+ }
26
+
27
+ /**
28
+ * Each concrete repository must define:
29
+ * static tableName = 'table_name'
30
+ */
31
+ get tableName() {
32
+ if (!this.constructor.tableName) {
33
+ throw new Error(`tableName not defined for ${this.constructor.name}`)
34
+ }
35
+
36
+ return this.constructor.tableName
37
+ }
38
+
39
+ /**
40
+ * Builds the base knex query.
41
+ * Applies constructor-level baseQueryBuilder if provided.
42
+ */
43
+ baseQuery(options = {}, params) {
44
+ const { trx } = options
45
+ const connection = trx || this.db
46
+
47
+ const qb = connection(this.tableName)
48
+
49
+ if (this._baseQueryBuilder) {
50
+ // Pass full params for optional dynamic shaping
51
+ return this._baseQueryBuilder(qb, params)
52
+ }
53
+
54
+ return qb
55
+ }
56
+
57
+ /**
58
+ * Generic paginated find
59
+ */
60
+ async find({
61
+ page = 1,
62
+ limit = 10,
63
+ filter = {},
64
+ orderBy = { column: 'created_at', direction: 'desc' },
65
+ options = {},
66
+ mapRow = (row) => row,
67
+ ...restParams
68
+ }) {
69
+ try {
70
+ const qb = this.baseQuery(options, {
71
+ page,
72
+ limit,
73
+ filter,
74
+ orderBy,
75
+ options,
76
+ ...restParams,
77
+ })
78
+
79
+ return await sqlPaginate({
80
+ page,
81
+ limit,
82
+ orderBy,
83
+ baseQuery: qb,
84
+ mapRow,
85
+ filter: toSnakeCase(filter),
86
+ })
87
+ } catch (error) {
88
+ if (this.log) {
89
+ this.log.error({ error }, `Error finding from ${this.tableName}`)
90
+ }
91
+
92
+ throw error
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * TenantScopedRepository
3
+ *
4
+ * Extends BaseRepository
5
+ * Enforces tenantId presence inside filter.
6
+ *
7
+ * Does NOT modify filter.
8
+ * Does NOT rebuild filter.
9
+ * Only validates existence.
10
+ */
11
+
12
+ import { BaseRepository } from './BaseRepository.js'
13
+
14
+ export class TenantScopedRepository extends BaseRepository {
15
+ /**
16
+ * Extracts and validates tenantId from filter
17
+ */
18
+ getRequiredTenantId(filter = {}) {
19
+ const { tenantId } = filter
20
+
21
+ if (!tenantId) {
22
+ throw new Error(
23
+ `Scoped repository (${this.tableName}), tenantId is required`,
24
+ )
25
+ }
26
+
27
+ return tenantId
28
+ }
29
+
30
+ /**
31
+ * Overrides find to enforce tenant presence
32
+ */
33
+ async find(params = {}) {
34
+ const { filter = {} } = params
35
+
36
+ this.getRequiredTenantId(filter)
37
+
38
+ return super.find(params)
39
+ }
40
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
3
+
4
+ // 🔥 mock dependencies
5
+ vi.mock('../../src/postgresql/pagination/paginate.js', () => ({
6
+ sqlPaginate: vi.fn().mockResolvedValue({ page: 1 }),
7
+ }))
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)),
19
+ }))
20
+
21
+ // import AFTER mocks
22
+ import { sqlPaginate } from '../../src/postgresql/pagination/paginate.js'
23
+ import { toSnakeCase } from '../../src/core/case-mapper.js'
24
+
25
+ describe('BaseRepository', () => {
26
+ let dbMock
27
+ let logMock
28
+
29
+ class TestRepo extends BaseRepository {
30
+ static tableName = 'test_table'
31
+ }
32
+
33
+ beforeEach(() => {
34
+ dbMock = vi.fn(() => ({
35
+ clone: vi.fn(),
36
+ }))
37
+
38
+ logMock = {
39
+ error: vi.fn(),
40
+ }
41
+ })
42
+
43
+ it('throws if db not provided', () => {
44
+ expect(() => new BaseRepository()).toThrow()
45
+ })
46
+
47
+ it('returns correct tableName', () => {
48
+ const repo = new TestRepo({ db: dbMock })
49
+ expect(repo.tableName).toBe('test_table')
50
+ })
51
+
52
+ it('baseQuery uses db when no trx', () => {
53
+ const repo = new TestRepo({ db: dbMock })
54
+
55
+ repo.baseQuery()
56
+
57
+ expect(dbMock).toHaveBeenCalledWith('test_table')
58
+ })
59
+
60
+ it('baseQuery uses trx when provided', () => {
61
+ const trxMock = vi.fn()
62
+ const repo = new TestRepo({ db: dbMock })
63
+
64
+ repo.baseQuery({ trx: trxMock })
65
+
66
+ expect(trxMock).toHaveBeenCalledWith('test_table')
67
+ expect(dbMock).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it('baseQuery applies baseQueryBuilder', () => {
71
+ const builderMock = vi.fn((qb) => qb)
72
+ const repo = new TestRepo({
73
+ db: dbMock,
74
+ baseQueryBuilder: builderMock,
75
+ })
76
+
77
+ repo.baseQuery({}, { some: 'param' })
78
+
79
+ expect(builderMock).toHaveBeenCalled()
80
+ })
81
+
82
+ it('find calls sqlPaginate with correct params', async () => {
83
+ const repo = new TestRepo({ db: dbMock })
84
+
85
+ await repo.find({
86
+ page: 2,
87
+ limit: 5,
88
+ filter: { a: 1 },
89
+ })
90
+
91
+ expect(sqlPaginate).toHaveBeenCalled()
92
+ })
93
+
94
+ it('logs error if sqlPaginate throws', async () => {
95
+ // @ts-ignore
96
+ sqlPaginate.mockRejectedValueOnce(new Error('fail'))
97
+
98
+ const repo = new TestRepo({ db: dbMock, log: logMock })
99
+
100
+ await expect(repo.find({ filter: {} })).rejects.toThrow()
101
+
102
+ expect(logMock.error).toHaveBeenCalled()
103
+ })
104
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
3
+
4
+ class TestTenantRepo extends TenantScopedRepository {
5
+ static tableName = 'tenant_table'
6
+ }
7
+
8
+ describe('TenantScopedRepository', () => {
9
+ const dbMock = vi.fn(() => ({}))
10
+
11
+ it('throws if tenantId missing', async () => {
12
+ const repo = new TestTenantRepo({ db: dbMock })
13
+
14
+ await expect(repo.find({ filter: {} })).rejects.toThrow()
15
+ })
16
+
17
+ it('does not throw if tenantId exists', async () => {
18
+ const repo = new TestTenantRepo({ db: dbMock })
19
+
20
+ // Spy on BaseRepository.find instead of overriding repo.find
21
+ const superSpy = vi
22
+ .spyOn(Object.getPrototypeOf(TenantScopedRepository.prototype), 'find')
23
+ .mockResolvedValue(true)
24
+
25
+ const result = await repo.find({
26
+ filter: { tenantId: 'abc' },
27
+ })
28
+
29
+ expect(result).toBe(true)
30
+ expect(superSpy).toHaveBeenCalled()
31
+
32
+ superSpy.mockRestore()
33
+ })
34
+
35
+ it('getRequiredTenantId returns tenantId', () => {
36
+ const repo = new TestTenantRepo({ db: dbMock })
37
+
38
+ const id = repo.getRequiredTenantId({
39
+ tenantId: 'tenant-1',
40
+ })
41
+
42
+ expect(id).toBe('tenant-1')
43
+ })
44
+ })