hlquery-node-client 1.0.2 → 1.0.3

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.
@@ -1,4 +1,4 @@
1
- name: Node API CI
1
+ name: Node build
2
2
 
3
3
  on:
4
4
  push:
package/README.md CHANGED
@@ -6,17 +6,17 @@
6
6
 
7
7
  **A modular Node.js client library for hlquery, designed with a familiar and intuitive API structure.**
8
8
 
9
- [![Follow hlquery](https://img.shields.io/badge/Follow-%40hlquery-blue?logo=x&logoColor=white)](https://x.com/hlquery)
10
- [![Commit Activity](https://img.shields.io/github/commit-activity/m/hlquery/node-api)](https://github.com/hlquery/node-api/pulse)
11
- [![GitHub](https://img.shields.io/badge/GitHub-node--api-181717?logo=github&logoColor=white)](https://github.com/hlquery/node-api/stargazers)
12
- [![hlquery](https://img.shields.io/badge/GitHub-hlquery-blue?logo=github&logoColor=white)](https://github.com/hlquery/hlquery/stargazers)
13
- [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
9
+ [![Follow hlquery](https://img.shields.io/badge/Follow-%40hlquery-blue?logo=x&logoColor=white&labelColor=000000)](https://x.com/hlquery)
10
+ [![Node build](https://img.shields.io/badge/Node%20build-passing-brightgreen?logo=node.js&logoColor=white&labelColor=000000)](https://github.com/hlquery/node-api/actions/workflows/ci.yml)
11
+ [![node-api](https://img.shields.io/badge/GitHub-node--api-purple?logo=github&logoColor=white&labelColor=000000)](https://github.com/hlquery/node-api/stargazers)
12
+ [![hlquery](https://img.shields.io/badge/GitHub-hlquery-blue?logo=github&logoColor=white&labelColor=000000)](https://github.com/hlquery/hlquery/stargazers)
13
+ [![License](https://img.shields.io/badge/License-BSD%203--Clause-a35a0f?logo=open-source-initiative&logoColor=white&labelColor=000000)](https://opensource.org/licenses/BSD-3-Clause)
14
14
 
15
15
  </div>
16
16
 
17
17
  ### What is the hlquery Node.js API?
18
18
 
19
- The hlquery Node.js API is the official Node.js client for hlquery. It wraps the REST interface in a modular service-style client with helpers for collections, documents, search, SQL, and SAM.
19
+ The hlquery Node.js API is the official Node.js client for [hlquery](https://github.com/hlquery/hlquery). It wraps the REST interface in a modular service-style client with helpers for collections, documents, search, SQL, and SAM.
20
20
 
21
21
  It is a good fit for backend services, scripts, dashboards, and apps that want hlquery integration without repeating request code.
22
22
 
@@ -26,10 +26,6 @@ Use the Node.js client when you want hlquery calls to read like regular applicat
26
26
 
27
27
  It also keeps the repetitive parts in one place: authentication, request parameters, endpoint paths, and parsed responses are handled consistently across the client. Common hlquery workflows are covered by default, while raw request access is still available when you need a custom route.
28
28
 
29
- ### Why choose it over raw HTTP?
30
-
31
- Choose the Node.js client over raw HTTP when you want less repetitive `fetch` or `axios` boilerplate, cleaner handling for auth headers and endpoint paths, and search or indexing code that stays easier to read.
32
-
33
29
  ### Install
34
30
 
35
31
  ```bash
@@ -158,19 +154,6 @@ console.log(execResult.getBody());
158
154
  console.log(books.getBody());
159
155
  ```
160
156
 
161
- ### Reduce Text Example
162
-
163
- Use the raw request helper for custom module routes:
164
-
165
- ```javascript
166
- const response = await client.executeRequest('GET', '/modules/<name>/<route>', null, {
167
- q: 'example query'
168
- });
169
-
170
- /* Print the custom module route response body. */
171
- console.log(response.getBody());
172
- ```
173
-
174
157
  ### Contributing
175
158
 
176
159
  We welcome contributions from the community! All contributions must be released under the BSD 3-Clause license.
package/lib/Client.js CHANGED
@@ -393,6 +393,80 @@ class Client {
393
393
  async getCollectionFields(name) {
394
394
  return await this._collections.getFields(name);
395
395
  }
396
+
397
+ /**
398
+ * Copy a collection into a new name (schema + documents).
399
+ *
400
+ * @param {string} sourceName
401
+ * @param {string} targetName
402
+ * @param {object} options
403
+ * @returns {Promise<Response>}
404
+ */
405
+ async copyCollection(sourceName, targetName, options = {}) {
406
+ const batchSize = Number.isInteger(options.batch_size) ? options.batch_size
407
+ : (Number.isInteger(options.batchSize) ? options.batchSize : 500);
408
+
409
+ const sourceResponse = await this._collections.get(sourceName);
410
+ if (sourceResponse.getStatusCode() !== 200) {
411
+ return sourceResponse;
412
+ }
413
+
414
+ const source = sourceResponse.getBody();
415
+ const schema = {};
416
+
417
+ if (source && source.fields && typeof source.fields === 'object' && !Array.isArray(source.fields) && Object.keys(source.fields).length > 0) {
418
+ const fieldNames = Object.keys(source.fields).sort();
419
+ schema.fields = fieldNames.map(name => ({ name, type: typeof source.fields[name] === 'string' ? source.fields[name] : 'string' }));
420
+ } else if (source && Array.isArray(source.searchable_fields) && source.searchable_fields.length > 0) {
421
+ schema.searchable_fields = source.searchable_fields;
422
+ } else {
423
+ schema.searchable_fields = ['title', 'content'];
424
+ }
425
+
426
+ if (source && Array.isArray(source.filterable_fields)) {
427
+ schema.filterable_fields = source.filterable_fields;
428
+ }
429
+ if (source && Array.isArray(source.sortable_fields)) {
430
+ schema.sortable_fields = source.sortable_fields;
431
+ }
432
+
433
+ if (source && source.metadata && typeof source.metadata === 'object' && !Array.isArray(source.metadata)) {
434
+ for (const [key, value] of Object.entries(source.metadata)) {
435
+ if (typeof key === 'string' && key.startsWith('_')) {
436
+ schema[key] = value;
437
+ }
438
+ }
439
+ }
440
+
441
+ const createResponse = await this._collections.create(targetName, schema);
442
+ if (createResponse.getStatusCode() !== 201) {
443
+ return createResponse;
444
+ }
445
+
446
+ let offset = 0;
447
+
448
+ while (true) {
449
+ const listResponse = await this._documents.list(sourceName, { offset, limit: batchSize });
450
+ if (listResponse.getStatusCode() !== 200) {
451
+ return listResponse;
452
+ }
453
+
454
+ const body = listResponse.getBody() || {};
455
+ const documents = Array.isArray(body.documents) ? body.documents : [];
456
+ if (documents.length === 0) {
457
+ break;
458
+ }
459
+
460
+ const importResponse = await this._documents.import(targetName, documents);
461
+ if (!importResponse.isSuccess || !importResponse.isSuccess()) {
462
+ return importResponse;
463
+ }
464
+
465
+ offset += documents.length;
466
+ }
467
+
468
+ return createResponse;
469
+ }
396
470
 
397
471
  // ============================================================================
398
472
  // DOCUMENTS API
@@ -429,6 +503,30 @@ class Client {
429
503
  return await this._documents.get(collectionName, documentId);
430
504
  }
431
505
 
506
+ /**
507
+ * Copy a document to a new ID within the same collection.
508
+ *
509
+ * @param {string} collectionName
510
+ * @param {string} sourceId
511
+ * @param {string} targetId
512
+ * @returns {Promise<Response>}
513
+ */
514
+ async copyDocument(collectionName, sourceId, targetId) {
515
+ return await this._documents.copy(collectionName, sourceId, targetId);
516
+ }
517
+
518
+ /**
519
+ * Fetch most-recent documents (by timestamp desc).
520
+ *
521
+ * @param {string} collectionName
522
+ * @param {number} limit
523
+ * @param {number} offset
524
+ * @returns {Promise<Response>}
525
+ */
526
+ async recentDocuments(collectionName, limit = 20, offset = 0) {
527
+ return await this._documents.recent(collectionName, limit, offset);
528
+ }
529
+
432
530
  /**
433
531
  * Export documents from a collection.
434
532
  *
package/lib/Documents.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const Validator = require('../utils/Validator');
10
+ const Response = require('./Response');
10
11
  const PDFParser = require('../utils/PDFParser');
11
12
  const CSVParser = require('../utils/CSVParser');
12
13
 
@@ -167,16 +168,93 @@ class Documents {
167
168
  Validator.validateCollectionName(collectionName);
168
169
 
169
170
  // Validate each document in the array
170
- if (Array.isArray(documents)) {
171
- for (const doc of documents) {
172
- Validator.validateDocumentFields(doc);
173
- }
171
+ if (!Array.isArray(documents)) {
172
+ throw new Error('Documents import payload must be an array');
173
+ }
174
+
175
+ for (const doc of documents) {
176
+ Validator.validateDocumentFields(doc);
174
177
  }
175
178
 
176
- // Server expects {documents: [...]} format
177
- return await this.request.execute('POST', `/collections/${encodeURIComponent(collectionName)}/documents/import`, {
179
+ // Server expects {documents: [...]} format; fall back to per-doc inserts if bulk route is unavailable.
180
+ const bulkResponse = await this.request.execute('POST', `/collections/${encodeURIComponent(collectionName)}/documents/import`, {
178
181
  documents: documents
179
182
  });
183
+
184
+ if (bulkResponse && bulkResponse.getStatusCode && bulkResponse.getStatusCode() === 404) {
185
+ let inserted = 0;
186
+ for (const doc of documents) {
187
+ const response = await this.request.execute('POST', `/collections/${encodeURIComponent(collectionName)}/documents`, doc);
188
+ const status = response.getStatusCode();
189
+ if (status !== 201) {
190
+ return response;
191
+ }
192
+ inserted += 1;
193
+ }
194
+
195
+ return new Response(201, { imported: inserted, fallback: true });
196
+ }
197
+
198
+ return bulkResponse;
199
+ }
200
+
201
+ /**
202
+ * Copy one document to a new ID within the same collection.
203
+ *
204
+ * @param {string} collectionName
205
+ * @param {string} sourceId
206
+ * @param {string} targetId
207
+ * @returns {Promise<Response>}
208
+ */
209
+ async copy(collectionName, sourceId, targetId) {
210
+ Validator.validateCollectionName(collectionName);
211
+ Validator.validateDocumentId(sourceId);
212
+ Validator.validateDocumentId(targetId);
213
+
214
+ if (sourceId === targetId) {
215
+ throw new Error('Source and target document ids must differ');
216
+ }
217
+
218
+ const targetCheck = await this.get(collectionName, targetId);
219
+ if (targetCheck.getStatusCode() === 200) {
220
+ return targetCheck;
221
+ }
222
+ if (targetCheck.getStatusCode() !== 404) {
223
+ return targetCheck;
224
+ }
225
+
226
+ const sourceResponse = await this.get(collectionName, sourceId);
227
+ if (sourceResponse.getStatusCode() !== 200) {
228
+ return sourceResponse;
229
+ }
230
+
231
+ const doc = { ...sourceResponse.getBody(), id: targetId };
232
+ delete doc.collection_id;
233
+ delete doc.score;
234
+
235
+ const imported = await this.import(collectionName, [doc]);
236
+ return imported;
237
+ }
238
+
239
+ /**
240
+ * Convenience helper to fetch most-recent documents (by timestamp desc).
241
+ *
242
+ * @param {string} collectionName
243
+ * @param {number} limit
244
+ * @param {number} offset
245
+ * @returns {Promise<Response>}
246
+ */
247
+ async recent(collectionName, limit = 20, offset = 0) {
248
+ Validator.validateCollectionName(collectionName);
249
+ Validator.validatePagination(offset, limit);
250
+
251
+ const sql = `select * from ${collectionName} order by timestamp desc limit ${limit} offset ${offset}`;
252
+ return await this.request.execute(
253
+ 'GET',
254
+ `/collections/${encodeURIComponent(collectionName)}/documents/search`,
255
+ null,
256
+ { sql }
257
+ );
180
258
  }
181
259
 
182
260
  /**
package/lib/Keys.js CHANGED
@@ -35,7 +35,7 @@ class Keys {
35
35
  if (!id) {
36
36
  throw new Error('Key ID is required');
37
37
  }
38
- return await this.request.execute('GET', `/keys/${id}`);
38
+ return await this.request.execute('GET', `/keys/${encodeURIComponent(id)}`);
39
39
  }
40
40
 
41
41
  /**
@@ -64,7 +64,7 @@ class Keys {
64
64
  if (!id) {
65
65
  throw new Error('Key ID is required');
66
66
  }
67
- return await this.request.execute('DELETE', `/keys/${id}`);
67
+ return await this.request.execute('DELETE', `/keys/${encodeURIComponent(id)}`);
68
68
  }
69
69
 
70
70
  /**
@@ -78,7 +78,7 @@ class Keys {
78
78
  if (!id) {
79
79
  throw new Error('Key ID is required');
80
80
  }
81
- return await this.request.execute('PUT', `/keys/${id}`, params);
81
+ return await this.request.execute('PUT', `/keys/${encodeURIComponent(id)}`, params);
82
82
  }
83
83
  }
84
84
 
package/lib/Request.js CHANGED
@@ -47,20 +47,33 @@ class Request {
47
47
 
48
48
  // Add query parameters
49
49
  Object.keys(queryParams).forEach(key => {
50
- if (queryParams[key] !== undefined && queryParams[key] !== null) {
51
- url.searchParams.append(key, queryParams[key]);
50
+ const value = queryParams[key];
51
+ if (value === undefined || value === null) {
52
+ return;
52
53
  }
54
+
55
+ if (Array.isArray(value)) {
56
+ url.searchParams.append(key, value.join(','));
57
+ return;
58
+ }
59
+
60
+ if (typeof value === 'object') {
61
+ url.searchParams.append(key, JSON.stringify(value));
62
+ return;
63
+ }
64
+
65
+ url.searchParams.append(key, value);
53
66
  });
54
67
 
55
68
  const headers = {
56
- 'Content-Type': 'application/json',
57
69
  'Accept': 'application/json'
58
70
  };
59
71
 
60
72
  // Prepare body string if present
61
73
  let bodyStr = null;
62
- if (body !== null) {
74
+ if (body !== null && body !== undefined) {
63
75
  bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
76
+ headers['Content-Type'] = 'application/json';
64
77
  headers['Content-Length'] = Buffer.byteLength(bodyStr);
65
78
  }
66
79
 
package/lib/SAM.js CHANGED
@@ -87,10 +87,14 @@ class SAM {
87
87
 
88
88
  async history(collectionName = null, limit = 100, params = {}) {
89
89
  this._validateParams(params, 'SAM history params');
90
+ const safeLimit = Number(limit);
91
+ if (!Number.isInteger(safeLimit) || safeLimit < 1) {
92
+ throw new Error('SAM history limit must be a positive integer');
93
+ }
90
94
 
91
95
  const queryParams = {
92
96
  ...params,
93
- limit: Number(limit)
97
+ limit: safeLimit
94
98
  };
95
99
 
96
100
  if (collectionName !== null && collectionName !== undefined && collectionName !== '') {
@@ -122,12 +126,21 @@ class SAM {
122
126
  async listDocuments(collectionName, offset = 0, limit = 20, params = {}) {
123
127
  Validator.validateCollectionName(collectionName);
124
128
  this._validateParams(params, 'SAM document list params');
129
+ const safeOffset = Number(offset);
130
+ const safeLimit = Number(limit);
131
+
132
+ if (!Number.isInteger(safeOffset) || safeOffset < 0) {
133
+ throw new Error('SAM document offset must be a non-negative integer');
134
+ }
135
+ if (!Number.isInteger(safeLimit) || safeLimit < 1) {
136
+ throw new Error('SAM document limit must be a positive integer');
137
+ }
125
138
 
126
139
  return await this.request.execute('GET', '/sam/documents', null, {
127
140
  ...params,
128
141
  collection: collectionName,
129
- offset: Number(offset),
130
- limit: Number(limit)
142
+ offset: safeOffset,
143
+ limit: safeLimit
131
144
  });
132
145
  }
133
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlquery-node-client",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Node.js client library for hlquery search engine",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -9,6 +9,7 @@ const Request = require('../lib/Request');
9
9
  const Response = require('../lib/Response');
10
10
  const Config = require('../utils/Config');
11
11
  const Validator = require('../utils/Validator');
12
+ const CSVParser = require('../utils/CSVParser');
12
13
 
13
14
  async function main() {
14
15
  assert.strictEqual(typeof Hlquery, 'function', 'default export should be the Client constructor');
@@ -23,7 +24,11 @@ async function main() {
23
24
  Validator.validateSearchParams({ limit: 10, offset: 0 });
24
25
 
25
26
  assert.throws(() => Validator.validateCollectionName('123bad'), /letter or underscore/);
26
- assert.throws(() => Validator.validateDocumentFields({ title: 'bad,value' }), /comma/);
27
+ assert.throws(() => Validator.validateSearchParams(null), /object/);
28
+ assert.throws(() => Validator.validateSearchParams({ limit: NaN }), /positive integer/);
29
+ assert.throws(() => Validator.validateSearchParams({ offset: 1.5 }), /non-negative integer/);
30
+ assert.throws(() => Validator.validateDocumentFields({ title: 'bad,value' }), /comma \(\,\)/);
31
+ assert.throws(() => CSVParser.parseCSV('a||b', '||'), /single character/);
27
32
 
28
33
  const response = new Response(200, { ok: true }, { 'content-type': 'application/json' });
29
34
  assert.strictEqual(response.isSuccess(), true);
@@ -78,15 +83,23 @@ async function main() {
78
83
  return;
79
84
  }
80
85
 
81
- if (req.url === '/echo-auth') {
86
+ if (req.url.startsWith('/echo-auth')) {
82
87
  res.writeHead(200, { 'Content-Type': 'application/json' });
83
88
  res.end(JSON.stringify({
84
89
  authorization: req.headers.authorization || null,
85
- apiKey: req.headers['x-api-key'] || null
90
+ apiKey: req.headers['x-api-key'] || null,
91
+ contentType: req.headers['content-type'] || null,
92
+ url: req.url
86
93
  }));
87
94
  return;
88
95
  }
89
96
 
97
+ if (req.url === '/keys/key%2Fwith%20space') {
98
+ res.writeHead(200, { 'Content-Type': 'application/json' });
99
+ res.end(JSON.stringify({ id: 'key/with space' }));
100
+ return;
101
+ }
102
+
90
103
  res.writeHead(404, { 'Content-Type': 'application/json' });
91
104
  res.end(JSON.stringify({ error: 'not found' }));
92
105
  });
@@ -111,6 +124,16 @@ async function main() {
111
124
  const apiKeyEcho = await request.execute('GET', '/echo-auth');
112
125
  assert.strictEqual(apiKeyEcho.getBody().apiKey, 'scoped-key');
113
126
 
127
+ const queryEcho = await request.execute('GET', '/echo-auth', undefined, {
128
+ fields: ['title', 'body'],
129
+ filter: { published: true },
130
+ skip: null
131
+ });
132
+ assert.strictEqual(queryEcho.getBody().contentType, null);
133
+ assert.match(queryEcho.getBody().url, /fields=title%2Cbody/);
134
+ assert.match(queryEcho.getBody().url, /filter=%7B%22published%22%3Atrue%7D/);
135
+ assert.doesNotMatch(queryEcho.getBody().url, /skip=/);
136
+
114
137
  const client = new Client(`http://127.0.0.1:${port}`);
115
138
  const clientHealth = await client.health();
116
139
  assert.strictEqual(clientHealth.isSuccess(), true);
@@ -152,6 +175,12 @@ async function main() {
152
175
  assert.strictEqual(typeof client.sam().listDocuments, 'function');
153
176
  assert.strictEqual(typeof client.sam().getDocument, 'function');
154
177
  assert.strictEqual(typeof client.sam().openDocument, 'function');
178
+ await assert.rejects(() => client.sam().history(null, 0), /positive integer/);
179
+ await assert.rejects(() => client.sam().listDocuments('books', NaN, 20), /non-negative integer/);
180
+
181
+ const keyResponse = await client.keys().get('key/with space');
182
+ assert.deepStrictEqual(keyResponse.getBody(), { id: 'key/with space' });
183
+ await assert.rejects(() => client.documents().import('books', { id: 'not-array' }), /array/);
155
184
  } finally {
156
185
  await new Promise((resolve) => server.close(resolve));
157
186
  }
@@ -14,6 +14,7 @@ const { ValidationException } = require('../lib/Exceptions');
14
14
  class CSVParser {
15
15
  static parseFile(filePath, options = {}) {
16
16
  this.validateFilePath(filePath);
17
+ this.validateDelimiter(options.delimiter || ',');
17
18
 
18
19
  const resolvedPath = path.resolve(filePath);
19
20
  const stat = fs.statSync(resolvedPath);
@@ -62,6 +63,8 @@ class CSVParser {
62
63
  }
63
64
 
64
65
  static parseCSV(input, delimiter) {
66
+ this.validateDelimiter(delimiter);
67
+
65
68
  const rows = [];
66
69
  let row = [];
67
70
  let value = '';
@@ -155,6 +158,16 @@ class CSVParser {
155
158
  throw new ValidationException('CSV file path must be a non-empty string');
156
159
  }
157
160
  }
161
+
162
+ static validateDelimiter(delimiter) {
163
+ if (typeof delimiter !== 'string' || delimiter.length !== 1) {
164
+ throw new ValidationException('CSV delimiter must be a single character');
165
+ }
166
+
167
+ if (delimiter === '"' || delimiter === '\r' || delimiter === '\n') {
168
+ throw new ValidationException('CSV delimiter cannot be a quote or newline');
169
+ }
170
+ }
158
171
  }
159
172
 
160
173
  module.exports = CSVParser;
@@ -69,35 +69,33 @@ class Validator {
69
69
  * Validate pagination parameters
70
70
  */
71
71
  static validatePagination(offset, limit) {
72
- if (offset !== undefined && (typeof offset !== 'number' || offset < 0)) {
73
- throw new ValidationException('Offset must be a non-negative number');
72
+ if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) {
73
+ throw new ValidationException('Offset must be a non-negative integer');
74
74
  }
75
- if (limit !== undefined && (typeof limit !== 'number' || limit < 1)) {
76
- throw new ValidationException('Limit must be a positive number');
75
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
76
+ throw new ValidationException('Limit must be a positive integer');
77
77
  }
78
78
  }
79
79
 
80
80
  /**
81
81
  * Validate search parameters
82
82
  */
83
- static validateSearchParams(params) {
84
- if (params && typeof params !== 'object') {
83
+ static validateSearchParams(params = {}) {
84
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
85
85
  throw new ValidationException('Search parameters must be an object');
86
86
  }
87
87
 
88
- if (params) {
89
- if (params.offset !== undefined && (typeof params.offset !== 'number' || params.offset < 0)) {
90
- throw new ValidationException('Offset must be a non-negative number');
91
- }
92
- if (params.limit !== undefined && (typeof params.limit !== 'number' || params.limit < 1)) {
93
- throw new ValidationException('Limit must be a positive number');
94
- }
95
- if (params.from !== undefined && (typeof params.from !== 'number' || params.from < 0)) {
96
- throw new ValidationException('From must be a non-negative number');
97
- }
98
- if (params.size !== undefined && (typeof params.size !== 'number' || params.size < 1)) {
99
- throw new ValidationException('Size must be a positive number');
100
- }
88
+ if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
89
+ throw new ValidationException('Offset must be a non-negative integer');
90
+ }
91
+ if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
92
+ throw new ValidationException('Limit must be a positive integer');
93
+ }
94
+ if (params.from !== undefined && (!Number.isInteger(params.from) || params.from < 0)) {
95
+ throw new ValidationException('From must be a non-negative integer');
96
+ }
97
+ if (params.size !== undefined && (!Number.isInteger(params.size) || params.size < 1)) {
98
+ throw new ValidationException('Size must be a positive integer');
101
99
  }
102
100
  }
103
101
 
@@ -120,7 +118,7 @@ class Validator {
120
118
  // Check string values for commas
121
119
  if (typeof value === 'string' && value.includes(',')) {
122
120
  throw new ValidationException(
123
- `Field '${key}' contains invalid character: comma (`,`). ` +
121
+ `Field '${key}' contains invalid character: comma (,). ` +
124
122
  `Commas are not allowed in field values. Use underscores (_) or spaces instead, or use arrays for multiple values.`
125
123
  );
126
124
  }
@@ -130,7 +128,7 @@ class Validator {
130
128
  for (const item of value) {
131
129
  if (typeof item === 'string' && item.includes(',')) {
132
130
  throw new ValidationException(
133
- `Field '${key}' contains invalid character: comma (`,`). ` +
131
+ `Field '${key}' contains invalid character: comma (,). ` +
134
132
  `Array items cannot contain commas. Use underscores (_) or spaces instead.`
135
133
  );
136
134
  }
File without changes