core-services-sdk 1.3.63 → 1.3.65

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/core/normalize-phone-number.js +144 -25
  3. package/src/postgresql/core/get-table-name.js +10 -0
  4. package/src/postgresql/filters/apply-filter-object.js +19 -0
  5. package/src/postgresql/filters/apply-filter-snake-case.js +16 -0
  6. package/src/postgresql/filters/apply-filter.js +33 -0
  7. package/src/postgresql/filters/operators.js +32 -0
  8. package/src/postgresql/index.js +5 -2
  9. package/src/postgresql/modifiers/apply-order-by.js +19 -0
  10. package/src/postgresql/modifiers/apply-pagination.js +12 -0
  11. package/src/postgresql/pagination/paginate.js +48 -0
  12. package/tests/core/normalize-phone-number.unit.test.js +45 -0
  13. package/tests/postgresql/apply-filter-snake-case.integration.test.js +220 -0
  14. package/tests/postgresql/apply-filter.integration.test.js +34 -353
  15. package/tests/postgresql/core/get-table-name.unit.test.js +20 -0
  16. package/tests/postgresql/filters/apply-filter-object.test.js +23 -0
  17. package/tests/postgresql/filters/operators.unit.test.js +23 -0
  18. package/tests/postgresql/modifiers/apply-order-by.test.js +80 -0
  19. package/tests/postgresql/modifiers/apply-pagination.unit.test.js +18 -0
  20. package/tests/postgresql/paginate.integration.test.js +10 -18
  21. package/tests/postgresql/pagination/paginate.js +48 -0
  22. package/tests/postgresql/validate-schema.integration.test.js +9 -5
  23. package/types/core/normalize-phone-number.d.ts +97 -27
  24. package/types/postgresql/apply-filter.d.ts +131 -20
  25. package/types/postgresql/core/get-table-name.d.ts +10 -0
  26. package/types/postgresql/filters/apply-filter-object.d.ts +15 -0
  27. package/types/postgresql/filters/apply-filter-snake-case.d.ts +14 -0
  28. package/types/postgresql/filters/apply-filter.d.ts +15 -0
  29. package/types/postgresql/filters/operators.d.ts +14 -0
  30. package/types/postgresql/index.d.ts +5 -2
  31. package/types/postgresql/modifiers/apply-order-by.d.ts +17 -0
  32. package/types/postgresql/modifiers/apply-pagination.d.ts +17 -0
  33. package/types/postgresql/pagination/paginate.d.ts +29 -0
  34. package/src/postgresql/apply-filter.js +0 -275
  35. package/src/postgresql/paginate.js +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.63",
3
+ "version": "1.3.65",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -3,7 +3,46 @@
3
3
 
4
4
  import * as raw from 'google-libphonenumber'
5
5
 
6
- /** Resolve libphonenumber regardless of interop shape */
6
+ /**
7
+ * @typedef {Object} NormalizedPhone
8
+ * @property {string} e164
9
+ * @property {number} type
10
+ * @property {string} national
11
+ * @property {number} countryCode
12
+ * @property {string} nationalClean
13
+ * @property {string} international
14
+ * @property {string} countryCodeE164
15
+ * @property {string} internationalClean
16
+ * @property {string | undefined} regionCode
17
+ */
18
+
19
+ /**
20
+ * normalize-phone-number.js
21
+ *
22
+ * Utilities for parsing, validating, and normalizing phone numbers
23
+ * using google-libphonenumber.
24
+ *
25
+ * Supports both ESM and CJS interop builds.
26
+ * All normalization outputs are canonical and safe for persistence,
27
+ * comparison, indexing, and login identifiers.
28
+ */
29
+
30
+ /**
31
+ * Resolve google-libphonenumber exports regardless of ESM / CJS shape.
32
+ *
33
+ * Some builds expose:
34
+ * - raw.PhoneNumberUtil
35
+ * Others expose:
36
+ * - raw.default.PhoneNumberUtil
37
+ *
38
+ * This helper guarantees a consistent API.
39
+ *
40
+ * @returns {{
41
+ * PhoneNumberUtil: any,
42
+ * PhoneNumberFormat: any
43
+ * }}
44
+ * @throws {Error} If required exports are missing
45
+ */
7
46
  export function getLib() {
8
47
  // Prefer direct (CJS-style or ESM w/ named), else default
9
48
  // e.g. raw.PhoneNumberUtil OR raw.default.PhoneNumberUtil
@@ -19,6 +58,15 @@ export function getLib() {
19
58
 
20
59
  let _util // lazy singleton
21
60
 
61
+ /**
62
+ * Lazy singleton accessor for PhoneNumberUtil.
63
+ *
64
+ * Ensures:
65
+ * - Single instance per process
66
+ * - No eager initialization cost
67
+ *
68
+ * @returns {any} PhoneNumberUtil instance
69
+ */
22
70
  export function phoneUtil() {
23
71
  if (!_util) {
24
72
  const { PhoneNumberUtil } = getLib()
@@ -27,15 +75,25 @@ export function phoneUtil() {
27
75
  return _util
28
76
  }
29
77
 
78
+ /**
79
+ * Internal helper returning PhoneNumberFormat enum.
80
+ *
81
+ * @returns {any} PhoneNumberFormat enum
82
+ */
30
83
  function formats() {
31
84
  const { PhoneNumberFormat } = getLib()
32
85
  return PhoneNumberFormat
33
86
  }
34
87
 
35
88
  /**
36
- * Trim and remove invisible RTL markers.
37
- * @param {string} input
38
- * @returns {string}
89
+ * Cleans user input before parsing:
90
+ * - Trims whitespace
91
+ * - Removes invisible RTL/LTR markers
92
+ *
93
+ * Does NOT remove digits, plus sign, or formatting characters.
94
+ *
95
+ * @param {string} input Raw user input
96
+ * @returns {string} Cleaned input string
39
97
  */
40
98
  function clean(input) {
41
99
  return String(input)
@@ -44,27 +102,69 @@ function clean(input) {
44
102
  }
45
103
 
46
104
  /**
47
- * Convert a parsed libphonenumber object into a normalized result.
105
+ * Converts a parsed google-libphonenumber object into a normalized result.
106
+ *
107
+ * Returned formats:
108
+ * - e164: Canonical phone number in E.164 format.
109
+ * Intended for storage, indexing, comparison, login identifiers, and OTP flows.
110
+ *
111
+ * - national: Local, human-readable phone number representation.
112
+ * May include formatting characters such as dashes or spaces.
113
+ *
114
+ * - nationalClean: Local phone number containing digits only.
115
+ *
116
+ * - international: Human-readable international representation.
117
+ *
118
+ * - internationalClean: International phone number containing digits only,
119
+ * without '+' or formatting characters.
120
+ *
121
+ * - regionCode: ISO 3166-1 alpha-2 region code (e.g. "IL").
122
+ *
123
+ * - countryCallingCode: Numeric international dialing code (e.g. 972).
124
+ *
125
+ * - countryCallingCodeE164: International dialing code with '+' prefix (e.g. "+972").
126
+ *
127
+ * Notes:
128
+ * - Only `e164` should be persisted or used for identity comparison.
129
+ * - All other formats are intended strictly for UI, display, copy, or integrations.
130
+ *
48
131
  * @param {any} parsed
49
- * @returns {{e164:string,national:string,international:string,regionCode:string|undefined,type:number}}
132
+ * Parsed phone number object returned by google-libphonenumber.
133
+ *
134
+ * @returns {NormalizedPhone}
50
135
  */
136
+
51
137
  function toResult(parsed) {
52
138
  const PNF = formats()
53
139
  const util = phoneUtil()
54
- return {
140
+
141
+ const results = {
142
+ type: util.getNumberType(parsed),
55
143
  e164: util.format(parsed, PNF.E164),
56
144
  national: util.format(parsed, PNF.NATIONAL),
57
- international: util.format(parsed, PNF.INTERNATIONAL),
58
145
  regionCode: util.getRegionCodeForNumber(parsed),
59
- type: util.getNumberType(parsed),
146
+ international: util.format(parsed, PNF.INTERNATIONAL),
147
+ }
148
+
149
+ const countryCode = `${parsed.getCountryCode()}`
150
+
151
+ return {
152
+ ...results,
153
+ countryCode,
154
+ countryCodeE164: `+${countryCode}`,
155
+ nationalClean: results.national.replace(/\D/g, ''),
156
+ internationalClean: results.e164.replace(/\D/g, ''),
60
157
  }
61
158
  }
62
159
 
63
160
  /**
64
- * Parse & validate an international number (must start with '+').
65
- * @param {string} input
66
- * @returns {{e164:string,national:string,international:string,regionCode:string|undefined,type:number}}
67
- * @throws {Error} If the number is invalid
161
+ * Normalize and validate an international phone number.
162
+ *
163
+ * Input MUST start with '+'.
164
+ *
165
+ * @param {string} input International phone number (E.164-like)
166
+ * @returns {NormalizedPhone}
167
+ * @throws {Error} If the phone number is invalid
68
168
  */
69
169
  export function normalizePhoneOrThrowIntl(input) {
70
170
  try {
@@ -82,11 +182,22 @@ export function normalizePhoneOrThrowIntl(input) {
82
182
  }
83
183
 
84
184
  /**
85
- * Parse & validate a national number using a region hint.
86
- * @param {string} input
87
- * @param {string} defaultRegion
88
- * @returns {{e164:string,national:string,international:string,regionCode:string|undefined,type:number}}
89
- * @throws {Error} If the number is invalid
185
+ * Normalize and validate a national phone number using a region hint.
186
+ *
187
+ * Example:
188
+ * input: "0523444444"
189
+ * defaultRegion: "IL"
190
+ *
191
+ * @param {string} input National phone number
192
+ * @param {string} defaultRegion ISO 3166-1 alpha-2 country code
193
+ * @returns {{
194
+ * e164: string,
195
+ * national: string,
196
+ * international: string,
197
+ * regionCode: string | undefined,
198
+ * type: number
199
+ * }}
200
+ * @throws {Error} If the phone number is invalid
90
201
  */
91
202
  export function normalizePhoneOrThrowWithRegion(input, defaultRegion) {
92
203
  try {
@@ -104,13 +215,21 @@ export function normalizePhoneOrThrowWithRegion(input, defaultRegion) {
104
215
  }
105
216
 
106
217
  /**
107
- * Smart normalization:
108
- * - If input starts with '+', parse as international.
109
- * - Otherwise require a defaultRegion and parse as national.
110
- * @param {string} input
111
- * @param {{ defaultRegion?: string }} [opts]
112
- * @returns {{e164:string,national:string,international:string,regionCode:string|undefined,type:number}}
113
- * @throws {Error} If invalid or defaultRegion is missing for non-international input
218
+ * Smart normalization entry point.
219
+ *
220
+ * Behavior:
221
+ * - If input starts with '+', parses as international
222
+ * - Otherwise requires defaultRegion and parses as national
223
+ *
224
+ * This is the recommended function for login, signup, and verification flows.
225
+ *
226
+ * @param {string} input Phone number (international or national)
227
+ * @param {{
228
+ * defaultRegion?: string
229
+ * }} [opts]
230
+ *
231
+ * @returns {NormalizedPhone}
232
+ * @throws {Error} If invalid or defaultRegion is missing
114
233
  */
115
234
  export function normalizePhoneOrThrow(input, opts = {}) {
116
235
  const cleaned = clean(input)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Extracts base table name from Knex QueryBuilder.
3
+ * Relies on Knex internal structure.
4
+ *
5
+ * @param {import('knex').Knex.QueryBuilder} query
6
+ * @returns {string|undefined}
7
+ */
8
+ export function getTableNameFromQuery(query) {
9
+ return query?._single?.table
10
+ }
@@ -0,0 +1,19 @@
1
+ import { OPERATORS } from './operators.js'
2
+
3
+ /**
4
+ * Applies multiple operators on the same column.
5
+ *
6
+ * @param {import('knex').Knex.QueryBuilder} query
7
+ * @param {string} qualifiedKey
8
+ * @param {Object<string, *>} value
9
+ * @returns {import('knex').Knex.QueryBuilder}
10
+ */
11
+ export function applyFilterObject(query, qualifiedKey, value) {
12
+ return Object.entries(value).reduce((q, [operator, val]) => {
13
+ if (!OPERATORS[operator]) {
14
+ return q
15
+ }
16
+
17
+ return OPERATORS[operator](q, qualifiedKey, val)
18
+ }, query)
19
+ }
@@ -0,0 +1,16 @@
1
+ import { toSnakeCase } from '../../core/case-mapper.js'
2
+ import { applyFilter } from './apply-filter.js'
3
+
4
+ /**
5
+ * Applies filters with automatic camelCase to snake_case conversion.
6
+ *
7
+ * @param {Object} params
8
+ * @param {import('knex').Knex.QueryBuilder} params.query
9
+ * @param {Object} params.filter
10
+ */
11
+ export function applyFilterSnakeCase({ query, filter }) {
12
+ return applyFilter({
13
+ query,
14
+ filter: toSnakeCase(filter),
15
+ })
16
+ }
@@ -0,0 +1,33 @@
1
+ import { getTableNameFromQuery } from '../core/get-table-name.js'
2
+ import { OPERATORS } from './operators.js'
3
+ import { applyFilterObject } from './apply-filter-object.js'
4
+
5
+ /**
6
+ * Applies MongoDB-style filters to a Knex QueryBuilder.
7
+ *
8
+ * @param {Object} params
9
+ * @param {import('knex').Knex.QueryBuilder} params.query
10
+ * @param {Object} [params.filter]
11
+ * @returns {import('knex').Knex.QueryBuilder}
12
+ */
13
+ export function applyFilter({ query, filter = {} }) {
14
+ const tableName = getTableNameFromQuery(query)
15
+
16
+ if (!filter || Object.keys(filter).length === 0) {
17
+ return query
18
+ }
19
+
20
+ return Object.entries(filter).reduce((q, [key, value]) => {
21
+ const qualifiedKey = tableName ? `${tableName}.${key}` : key
22
+
23
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
24
+ return applyFilterObject(q, qualifiedKey, value)
25
+ }
26
+
27
+ if (Array.isArray(value)) {
28
+ return OPERATORS.in(q, qualifiedKey, value)
29
+ }
30
+
31
+ return OPERATORS.eq(q, qualifiedKey, value)
32
+ }, query)
33
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @typedef {Function} OperatorFunction
3
+ * @param {import('knex').Knex.QueryBuilder} q
4
+ * @param {string} key
5
+ * @param {*} value
6
+ * @returns {import('knex').Knex.QueryBuilder}
7
+ */
8
+
9
+ /**
10
+ * @type {Object<string, OperatorFunction>}
11
+ */
12
+ export const OPERATORS = {
13
+ in: (q, key, value) => q.whereIn(key, value),
14
+ nin: (q, key, value) => q.whereNotIn(key, value),
15
+
16
+ eq: (q, key, value) => q.where(key, '=', value),
17
+ ne: (q, key, value) => q.where(key, '!=', value),
18
+ neq: (q, key, value) => q.where(key, '!=', value),
19
+
20
+ gt: (q, key, value) => q.where(key, '>', value),
21
+ gte: (q, key, value) => q.where(key, '>=', value),
22
+ lt: (q, key, value) => q.where(key, '<', value),
23
+ lte: (q, key, value) => q.where(key, '<=', value),
24
+
25
+ like: (q, key, value) => q.where(key, 'like', value),
26
+ ilike: (q, key, value) => q.where(key, 'ilike', value),
27
+
28
+ isNull: (q, key, value) => (value ? q.whereNull(key) : q.whereNotNull(key)),
29
+
30
+ isNotNull: (q, key, value) =>
31
+ value ? q.whereNotNull(key) : q.whereNull(key),
32
+ }
@@ -1,5 +1,8 @@
1
- export * from './paginate.js'
2
- export * from './apply-filter.js'
3
1
  export * from './connect-to-pg.js'
4
2
  export * from './validate-schema.js'
3
+ export * from './pagination/paginate.js'
4
+ export * from './filters/apply-filter.js'
5
+ export * from './modifiers/apply-order-by.js'
5
6
  export * from './start-stop-postgres-docker.js'
7
+ export * from './modifiers/apply-pagination.js'
8
+ export * from './filters/apply-filter-snake-case.js'
@@ -0,0 +1,19 @@
1
+ import { getTableNameFromQuery } from '../core/get-table-name.js'
2
+
3
+ /**
4
+ * Applies ORDER BY clause.
5
+ *
6
+ * @param {Object} params
7
+ * @param {import('knex').Knex.QueryBuilder} params.query
8
+ * @param {{ column: string, direction?: 'asc'|'desc' }} params.orderBy
9
+ */
10
+ export function applyOrderBy({ query, orderBy }) {
11
+ if (!orderBy?.column) {
12
+ return query
13
+ }
14
+
15
+ const tableName = getTableNameFromQuery(query)
16
+ const column = tableName ? `${tableName}.${orderBy.column}` : orderBy.column
17
+
18
+ return query.orderBy(column, orderBy.direction || 'asc')
19
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Applies LIMIT and OFFSET.
3
+ *
4
+ * @param {Object} params
5
+ * @param {import('knex').Knex.QueryBuilder} params.query
6
+ * @param {number} [params.page=1]
7
+ * @param {number} [params.limit=10]
8
+ */
9
+ export function applyPagination({ query, page = 1, limit = 10 }) {
10
+ const offset = (page - 1) * limit
11
+ return query.limit(limit).offset(offset)
12
+ }
@@ -0,0 +1,48 @@
1
+ import { applyFilter } from '../filters/apply-filter.js'
2
+ import { applyFilterSnakeCase } from '../filters/apply-filter-snake-case.js'
3
+ import { applyOrderBy } from '../modifiers/apply-order-by.js'
4
+ import { applyPagination } from '../modifiers/apply-pagination.js'
5
+ import { normalizeNumberOrDefault } from '../../core/normalize-premitives-types-or-default.js'
6
+
7
+ /**
8
+ * Executes paginated query.
9
+ *
10
+ * @async
11
+ */
12
+ export async function sqlPaginate({
13
+ mapRow,
14
+ orderBy,
15
+ page = 1,
16
+ baseQuery,
17
+ limit = 10,
18
+ filter = {},
19
+ snakeCase = true,
20
+ }) {
21
+ const listQuery = baseQuery.clone()
22
+ const countQuery = baseQuery.clone()
23
+
24
+ const applyFilterFn = snakeCase ? applyFilterSnakeCase : applyFilter
25
+
26
+ applyFilterFn({ query: listQuery, filter })
27
+ applyFilterFn({ query: countQuery, filter })
28
+
29
+ applyOrderBy({ query: listQuery, orderBy })
30
+ applyPagination({ query: listQuery, page, limit })
31
+
32
+ const [rows, countResult] = await Promise.all([
33
+ listQuery.select('*'),
34
+ countQuery.count('* as count').first(),
35
+ ])
36
+
37
+ const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
38
+ const totalPages = Math.ceil(totalCount / limit)
39
+
40
+ return {
41
+ totalCount,
42
+ totalPages,
43
+ currentPage: page,
44
+ hasPrevious: page > 1,
45
+ hasNext: page < totalPages,
46
+ list: mapRow ? rows.map(mapRow) : rows,
47
+ }
48
+ }
@@ -58,6 +58,51 @@ describe('phone normalization', () => {
58
58
  expect(out.regionCode).toBe('IL')
59
59
  })
60
60
 
61
+ describe('normalizePhoneOrThrow - international input', () => {
62
+ it('returns full normalized data for international input without defaultRegion', () => {
63
+ const out = normalizePhoneOrThrow('+972523443413')
64
+
65
+ // canonical
66
+ expect(out.e164).toBe('+972523443413')
67
+ // clean formats
68
+
69
+ expect(out.internationalClean).toBe('972523443413')
70
+ expect(out.nationalClean).toBe('0523443413')
71
+
72
+ // formatted (do not assert exact formatting)
73
+ expect(typeof out.national).toBe('string')
74
+ expect(typeof out.international).toBe('string')
75
+
76
+ // metadata
77
+ expect(out.regionCode).toBe('IL')
78
+ expect(out.countryCode).toBe('972')
79
+ expect(out.countryCodeE164).toBe('+972')
80
+ expect(typeof out.type).toBe('number')
81
+ })
82
+
83
+ it('returns identical normalized data when defaultRegion is provided', () => {
84
+ const out = normalizePhoneOrThrow('0523443413', {
85
+ defaultRegion: 'IL',
86
+ })
87
+
88
+ // canonical
89
+ expect(out.e164).toBe('+972523443413')
90
+
91
+ // clean formats
92
+ expect(out.internationalClean).toBe('972523443413')
93
+ expect(out.nationalClean).toBe('0523443413')
94
+
95
+ // formatted (do not assert exact formatting)
96
+ expect(typeof out.national).toBe('string')
97
+ expect(typeof out.international).toBe('string')
98
+
99
+ // metadata
100
+ expect(out.regionCode).toBe('IL')
101
+ expect(out.countryCode).toBe('972')
102
+ expect(out.countryCodeE164).toBe('+972')
103
+ expect(typeof out.type).toBe('number')
104
+ })
105
+ })
61
106
  it('normalizePhoneOrThrow (smart): throws if national with no defaultRegion', () => {
62
107
  expect(() => normalizePhoneOrThrow('054-123-4567')).toThrow(
63
108
  /defaultRegion is required/i,