hlquery-node-client 1.0.4 → 1.0.6

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/lib/Request.js CHANGED
@@ -16,11 +16,26 @@ const Response = require('./Response');
16
16
  * HTTP request handler
17
17
  */
18
18
  class Request {
19
- constructor(baseUrl, timeout = 30000, authToken = null, authMethod = 'bearer') {
20
- this.baseUrl = baseUrl.replace(/\/$/, '');
19
+ constructor(baseUrl, timeout = 30000, authToken = null, authMethod = 'bearer', options = {}) {
20
+ if (timeout && typeof timeout === 'object') {
21
+ options = timeout;
22
+ timeout = options.timeout || 30000;
23
+ authToken = options.token || null;
24
+ authMethod = options.auth_method || options.authMethod || 'bearer';
25
+ }
26
+
27
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
21
28
  this.timeout = timeout;
22
29
  this.authToken = authToken;
23
30
  this.authMethod = authMethod;
31
+ this.agent = options.agent || null;
32
+ this.httpAgent = options.http_agent || options.httpAgent || null;
33
+ this.httpsAgent = options.https_agent || options.httpsAgent || null;
34
+ this.defaultHeaders = options.headers || {};
35
+ this.maxResponseBytes = options.max_response_bytes || options.maxResponseBytes || 0;
36
+ this.throwOnError = Boolean(options.throw_on_error || options.throwOnError);
37
+ this.queryArrayFormat = options.query_array_format || options.queryArrayFormat || 'comma';
38
+ this.signal = options.signal || null;
24
39
  }
25
40
 
26
41
  setAuthToken(token, method = 'bearer') {
@@ -42,8 +57,21 @@ class Request {
42
57
  * @returns {Promise<Response>}
43
58
  * @throws {RequestException}
44
59
  */
45
- async execute(method, path, body = null, queryParams = {}) {
46
- const url = new URL(this.baseUrl + path);
60
+ async execute(method, path, body = null, queryParams = {}, requestOptions = {}) {
61
+ const url = new URL(path, `${this.baseUrl}/`);
62
+ const signal = requestOptions.signal || this.signal;
63
+ const maxResponseBytes = requestOptions.max_response_bytes || requestOptions.maxResponseBytes || this.maxResponseBytes;
64
+ const throwOnError = requestOptions.throw_on_error !== undefined
65
+ ? requestOptions.throw_on_error
66
+ : (requestOptions.throwOnError !== undefined ? requestOptions.throwOnError : this.throwOnError);
67
+ const queryArrayFormat = requestOptions.query_array_format || requestOptions.queryArrayFormat || this.queryArrayFormat;
68
+
69
+ if (signal && signal.aborted) {
70
+ throw new RequestException('Request aborted', 0, null, {
71
+ code: 'ABORT_ERR',
72
+ retryable: false
73
+ });
74
+ }
47
75
 
48
76
  // Add query parameters
49
77
  Object.keys(queryParams).forEach(key => {
@@ -53,7 +81,11 @@ class Request {
53
81
  }
54
82
 
55
83
  if (Array.isArray(value)) {
56
- url.searchParams.append(key, value.join(','));
84
+ if (queryArrayFormat === 'repeat' || queryArrayFormat === 'repeated') {
85
+ value.forEach(item => url.searchParams.append(key, item));
86
+ } else {
87
+ url.searchParams.append(key, value.join(','));
88
+ }
57
89
  return;
58
90
  }
59
91
 
@@ -66,7 +98,9 @@ class Request {
66
98
  });
67
99
 
68
100
  const headers = {
69
- 'Accept': 'application/json'
101
+ 'Accept': 'application/json',
102
+ ...this.defaultHeaders,
103
+ ...(requestOptions.headers || {})
70
104
  };
71
105
 
72
106
  // Prepare body string if present
@@ -89,32 +123,112 @@ class Request {
89
123
  const options = {
90
124
  method: method,
91
125
  headers: headers,
92
- timeout: this.timeout
126
+ timeout: requestOptions.timeout || this.timeout
93
127
  };
94
128
 
95
129
  const client = url.protocol === 'https:' ? https : http;
130
+ const agent = this.agent || (url.protocol === 'https:' ? this.httpsAgent : this.httpAgent);
131
+ if (agent) {
132
+ options.agent = agent;
133
+ }
96
134
 
97
135
  return new Promise((resolve, reject) => {
98
- const req = client.request(url, options, (res) => {
136
+ let settled = false;
137
+ let req = null;
138
+ const rejectOnce = (error) => {
139
+ if (settled) {
140
+ return;
141
+ }
142
+ settled = true;
143
+ if (signal && abortHandler && typeof signal.removeEventListener === 'function') {
144
+ signal.removeEventListener('abort', abortHandler);
145
+ } else if (signal && abortHandler && typeof signal.removeListener === 'function') {
146
+ signal.removeListener('abort', abortHandler);
147
+ }
148
+ reject(error);
149
+ };
150
+ const resolveOnce = (response) => {
151
+ if (settled) {
152
+ return;
153
+ }
154
+ settled = true;
155
+ if (signal && abortHandler && typeof signal.removeEventListener === 'function') {
156
+ signal.removeEventListener('abort', abortHandler);
157
+ } else if (signal && abortHandler && typeof signal.removeListener === 'function') {
158
+ signal.removeListener('abort', abortHandler);
159
+ }
160
+ resolve(response);
161
+ };
162
+ const abortHandler = () => {
163
+ if (req) {
164
+ req.destroy();
165
+ }
166
+ rejectOnce(new RequestException('Request aborted', 0, null, {
167
+ code: 'ABORT_ERR',
168
+ retryable: false
169
+ }));
170
+ };
171
+
172
+ if (signal && typeof signal.addEventListener === 'function') {
173
+ signal.addEventListener('abort', abortHandler, { once: true });
174
+ } else if (signal && typeof signal.once === 'function') {
175
+ signal.once('abort', abortHandler);
176
+ }
177
+
178
+ req = client.request(url, options, (res) => {
99
179
  const responseHeaders = {};
100
180
  Object.keys(res.headers).forEach(key => {
101
181
  responseHeaders[key.toLowerCase()] = res.headers[key];
102
182
  });
103
183
 
104
- let data = '';
184
+ const chunks = [];
185
+ let receivedBytes = 0;
105
186
 
106
187
  res.on('data', (chunk) => {
107
- data += chunk;
188
+ receivedBytes += chunk.length;
189
+ if (maxResponseBytes > 0 && receivedBytes > maxResponseBytes) {
190
+ res.destroy();
191
+ rejectOnce(new RequestException(
192
+ `Response exceeded maximum size of ${maxResponseBytes} bytes`,
193
+ res.statusCode,
194
+ null,
195
+ {
196
+ headers: responseHeaders,
197
+ retryable: false,
198
+ code: 'MAX_RESPONSE_BYTES'
199
+ }
200
+ ));
201
+ return;
202
+ }
203
+ chunks.push(chunk);
108
204
  });
109
205
 
110
206
  res.on('end', () => {
207
+ if (settled) {
208
+ return;
209
+ }
210
+ const rawBuffer = Buffer.concat(chunks);
211
+ const data = rawBuffer.toString('utf8');
111
212
  let decoded;
213
+ let parsed = true;
112
214
  try {
113
215
  decoded = data ? JSON.parse(data) : null;
114
216
  } catch (e) {
115
217
  // If JSON parsing fails, return raw response
116
218
  decoded = data;
219
+ parsed = false;
117
220
  }
221
+
222
+ const response = new Response(res.statusCode, decoded, responseHeaders, {
223
+ statusMessage: res.statusMessage || '',
224
+ rawBody: rawBuffer,
225
+ parsed: parsed,
226
+ request: {
227
+ method: method,
228
+ url: url.toString(),
229
+ path: path
230
+ }
231
+ });
118
232
 
119
233
  // Check for demo mode errors
120
234
  if (res.statusCode === 403 && decoded && typeof decoded === 'object') {
@@ -122,30 +236,51 @@ class Request {
122
236
  if (errorMsg.includes('Demo mode is enabled') ||
123
237
  errorMsg.includes('Operation not allowed') ||
124
238
  errorMsg.includes('Write operations are not allowed')) {
125
- return reject(new DemoModeException(
239
+ return rejectOnce(new DemoModeException(
126
240
  `Demo mode is enabled on the server. Write operations are blocked. Only search and read operations are permitted. Server message: ${decoded.message || decoded.error}`
127
241
  ));
128
242
  }
129
243
  // Check if server rejected token because auth is disabled
130
244
  if (errorMsg.includes('Authentication is disabled') ||
131
245
  errorMsg.includes('Tokens are not accepted when authentication is disabled')) {
132
- return reject(new AuthenticationException(
246
+ return rejectOnce(new AuthenticationException(
133
247
  `Authentication is disabled on the server. Remove the token from your client configuration. Server message: ${decoded.message || decoded.error}`
134
248
  ));
135
249
  }
136
250
  }
251
+
252
+ if (throwOnError && response.isError()) {
253
+ const errorMessage = response.getError() ||
254
+ `${res.statusCode} ${res.statusMessage || 'HTTP error'}`;
255
+ return rejectOnce(new RequestException(errorMessage, res.statusCode, decoded, {
256
+ response: response,
257
+ headers: responseHeaders,
258
+ rawBody: rawBuffer,
259
+ retryable: res.statusCode >= 500
260
+ }));
261
+ }
137
262
 
138
- resolve(new Response(res.statusCode, decoded, responseHeaders));
263
+ resolveOnce(response);
139
264
  });
140
265
  });
141
266
 
142
267
  req.on('error', (error) => {
143
- reject(new RequestException(`Request failed: ${error.message}`, 0));
268
+ if (settled) {
269
+ return;
270
+ }
271
+ rejectOnce(new RequestException(`Request failed: ${error.message}`, 0, null, {
272
+ cause: error,
273
+ retryable: true,
274
+ code: error.code || null
275
+ }));
144
276
  });
145
277
 
146
278
  req.on('timeout', () => {
147
279
  req.destroy();
148
- reject(new RequestException('Request timeout', 0));
280
+ rejectOnce(new RequestException('Request timeout', 0, null, {
281
+ retryable: true,
282
+ code: 'ETIMEDOUT'
283
+ }));
149
284
  });
150
285
 
151
286
  if (bodyStr !== null) {
package/lib/Response.js CHANGED
@@ -10,10 +10,15 @@
10
10
  * Response wrapper for API responses
11
11
  */
12
12
  class Response {
13
- constructor(statusCode, body, headers = {}) {
13
+ constructor(statusCode, body, headers = {}, options = {}) {
14
14
  this.statusCode = statusCode;
15
15
  this.body = body;
16
16
  this.headers = headers;
17
+ this.statusMessage = options.statusMessage || '';
18
+ this.rawBody = options.rawBody || null;
19
+ this.request = options.request || null;
20
+ this.contentType = headers['content-type'] || headers['Content-Type'] || null;
21
+ this.parsed = options.parsed !== undefined ? options.parsed : true;
17
22
  }
18
23
 
19
24
  getStatusCode() {
@@ -27,6 +32,26 @@ class Response {
27
32
  getHeaders() {
28
33
  return this.headers;
29
34
  }
35
+
36
+ getStatusMessage() {
37
+ return this.statusMessage;
38
+ }
39
+
40
+ getRawBody() {
41
+ return this.rawBody;
42
+ }
43
+
44
+ getRequest() {
45
+ return this.request;
46
+ }
47
+
48
+ getContentType() {
49
+ return this.contentType;
50
+ }
51
+
52
+ isParsed() {
53
+ return this.parsed;
54
+ }
30
55
 
31
56
  isSuccess() {
32
57
  return this.statusCode >= 200 && this.statusCode < 300;
@@ -46,7 +71,8 @@ class Response {
46
71
  toArray() {
47
72
  return {
48
73
  status: this.statusCode,
49
- body: this.body
74
+ body: this.body,
75
+ headers: this.headers
50
76
  };
51
77
  }
52
78
  }
package/lib/Route.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * hlquery Node.js Client - Fluent Route API
3
+ *
4
+ * Copyright (C) 2021-2026, Carlos F. Ferry <carlos.ferry@gmail.com>
5
+ *
6
+ * This file is part of hlquery, released under the BSD License version 3.
7
+ */
8
+
9
+ function requireNonEmptyString(value, label) {
10
+ if (typeof value !== 'string' || value.trim() === '') {
11
+ throw new Error(`${label} must be a non-empty string`);
12
+ }
13
+ }
14
+
15
+ function cleanRoute(route) {
16
+ if (route === null || route === undefined) {
17
+ return '';
18
+ }
19
+
20
+ if (Array.isArray(route)) {
21
+ return route
22
+ .map(segment => {
23
+ requireNonEmptyString(String(segment), 'Route segment');
24
+ return encodeURIComponent(String(segment).replace(/^\/+|\/+$/g, ''));
25
+ })
26
+ .filter(Boolean)
27
+ .join('/');
28
+ }
29
+
30
+ return String(route).replace(/^\/+|\/+$/g, '');
31
+ }
32
+
33
+ function joinPath(basePath, route) {
34
+ const base = String(basePath || '').replace(/\/+$/g, '');
35
+ const suffix = cleanRoute(route);
36
+
37
+ if (!suffix) {
38
+ return base || '/';
39
+ }
40
+
41
+ if (!base || base === '/') {
42
+ return `/${suffix}`;
43
+ }
44
+
45
+ return `${base}/${suffix}`;
46
+ }
47
+
48
+ /**
49
+ * Small Redis-style command builder for dynamic hlquery routes.
50
+ */
51
+ class RouteCommand {
52
+ constructor(request, path) {
53
+ this.request = request;
54
+ this.path = path || '/';
55
+ }
56
+
57
+ route(route) {
58
+ return new RouteCommand(this.request, joinPath(this.path, route));
59
+ }
60
+
61
+ async call(method, body = null, queryParams = {}, options = {}) {
62
+ requireNonEmptyString(method, 'HTTP method');
63
+ return await this.request.execute(method.toUpperCase(), this.path, body, queryParams, options);
64
+ }
65
+
66
+ async get(queryParams = {}, options = {}) {
67
+ return await this.call('GET', null, queryParams, options);
68
+ }
69
+
70
+ async post(body = null, queryParams = {}, options = {}) {
71
+ return await this.call('POST', body, queryParams, options);
72
+ }
73
+
74
+ async put(body = null, queryParams = {}, options = {}) {
75
+ return await this.call('PUT', body, queryParams, options);
76
+ }
77
+
78
+ async patch(body = null, queryParams = {}, options = {}) {
79
+ return await this.call('PATCH', body, queryParams, options);
80
+ }
81
+
82
+ async delete(queryParams = {}, options = {}) {
83
+ return await this.call('DELETE', null, queryParams, options);
84
+ }
85
+
86
+ async del(queryParams = {}, options = {}) {
87
+ return await this.delete(queryParams, options);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Fluent command builder rooted at /modules/{name}.
93
+ */
94
+ class ModuleRouteCommand extends RouteCommand {
95
+ constructor(request, name) {
96
+ requireNonEmptyString(name, 'Module name');
97
+ super(request, `/modules/${encodeURIComponent(name)}`);
98
+ this.name = name;
99
+ }
100
+
101
+ async syntax(queryParams = {}, options = {}) {
102
+ return await this.route('syntax').get(queryParams, options);
103
+ }
104
+ }
105
+
106
+ module.exports = {
107
+ RouteCommand,
108
+ ModuleRouteCommand,
109
+ cleanRoute,
110
+ joinPath
111
+ };
package/lib/SAM.js CHANGED
@@ -123,6 +123,15 @@ class SAM {
123
123
  return await this.pause(0, params);
124
124
  }
125
125
 
126
+ async improve(params = {}) {
127
+ this._validateParams(params, 'SAM improve params');
128
+ return await this.request.execute('POST', '/sam/improve', null, params);
129
+ }
130
+
131
+ async flushActorMetadata() {
132
+ return await this.request.execute('POST', '/sam/flush_actor_metadata');
133
+ }
134
+
126
135
  async listDocuments(collectionName, offset = 0, limit = 20, params = {}) {
127
136
  Validator.validateCollectionName(collectionName);
128
137
  this._validateParams(params, 'SAM document list params');
@@ -169,6 +178,22 @@ class SAM {
169
178
 
170
179
  return await this.getDocument(collectionName, documentId, queryParams);
171
180
  }
181
+
182
+ async addDocumentLabel(collectionName, documentId, labels) {
183
+ Validator.validateCollectionName(collectionName);
184
+ Validator.validateDocumentId(documentId);
185
+
186
+ const normalizedLabels = Array.isArray(labels) ? labels : [labels];
187
+ if (normalizedLabels.length === 0 || normalizedLabels.some(label => typeof label !== 'string' || label.trim() === '')) {
188
+ throw new Error('SAM document labels must contain at least one non-empty string');
189
+ }
190
+
191
+ return await this.request.execute(
192
+ 'POST',
193
+ `/sam/label/add/${encodeURIComponent(collectionName)}/${encodeURIComponent(documentId)}`,
194
+ { labels: normalizedLabels }
195
+ );
196
+ }
172
197
  }
173
198
 
174
199
  module.exports = SAM;
package/lib/Search.js CHANGED
@@ -15,6 +15,7 @@ class Search {
15
15
  constructor(request, collections) {
16
16
  this.request = request;
17
17
  this.collections = collections;
18
+ this.searchableFieldsCache = new Map();
18
19
  }
19
20
 
20
21
  /**
@@ -96,13 +97,18 @@ class Search {
96
97
  queryParams.query_by = Array.isArray(params.query_by)
97
98
  ? params.query_by.join(',')
98
99
  : params.query_by;
99
- } else if (params.q && params.q !== '') {
100
- // Auto-detect searchable fields
101
- const collection = await this.collections.get(collectionName);
102
- if (collection.getStatusCode() === 200) {
103
- const body = collection.getBody();
104
- if (body.searchable_fields && body.searchable_fields.length > 0) {
105
- queryParams.query_by = body.searchable_fields.join(',');
100
+ } else if (params.q && params.q !== '' && params.auto_detect_query_by !== false) {
101
+ const cacheKey = collectionName;
102
+ if (this.searchableFieldsCache.has(cacheKey)) {
103
+ queryParams.query_by = this.searchableFieldsCache.get(cacheKey);
104
+ } else {
105
+ const collection = await this.collections.get(collectionName);
106
+ if (collection.getStatusCode() === 200) {
107
+ const body = collection.getBody();
108
+ if (body.searchable_fields && body.searchable_fields.length > 0) {
109
+ queryParams.query_by = body.searchable_fields.join(',');
110
+ this.searchableFieldsCache.set(cacheKey, queryParams.query_by);
111
+ }
106
112
  }
107
113
  }
108
114
  }
package/lib/System.js CHANGED
@@ -30,6 +30,10 @@ class System {
30
30
  return await this.request.execute('GET', '/boot-status');
31
31
  }
32
32
 
33
+ async ready() {
34
+ return await this.request.execute('GET', '/ready');
35
+ }
36
+
33
37
  async info() {
34
38
  return await this.request.execute('GET', '/');
35
39
  }
@@ -46,6 +50,14 @@ class System {
46
50
  return await this.request.execute('GET', '/metrics.json');
47
51
  }
48
52
 
53
+ async metricsHistory() {
54
+ return await this.request.execute('GET', '/metrics/history');
55
+ }
56
+
57
+ async metricsHistoryAlias() {
58
+ return await this.request.execute('GET', '/metrics-history');
59
+ }
60
+
49
61
  async connections() {
50
62
  return await this.request.execute('GET', '/connections');
51
63
  }
@@ -105,6 +117,26 @@ class System {
105
117
  async storageStatus() {
106
118
  return await this.request.execute('GET', '/admin/storage_status');
107
119
  }
120
+
121
+ async searchConfig() {
122
+ return await this.request.execute('GET', '/search-config');
123
+ }
124
+
125
+ async llm() {
126
+ return await this.request.execute('GET', '/llm');
127
+ }
128
+
129
+ async updateCounters(params = {}) {
130
+ return await this.request.execute('POST', '/update-counters', null, params);
131
+ }
132
+
133
+ async debugCounters() {
134
+ return await this.request.execute('GET', '/debug/counters');
135
+ }
136
+
137
+ async repair(params = {}) {
138
+ return await this.request.execute('POST', '/repair', null, params);
139
+ }
108
140
  }
109
141
 
110
142
  module.exports = System;
package/lib/Users.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * hlquery Node.js Client - Users API
3
+ *
4
+ * Copyright (C) 2021-2026, Carlos F. Ferry <carlos.ferry@gmail.com>
5
+ *
6
+ * This file is part of hlquery, released under the BSD License version 3.
7
+ */
8
+
9
+ class Users {
10
+ constructor(request) {
11
+ this.request = request;
12
+ }
13
+
14
+ _validateName(name) {
15
+ if (typeof name !== 'string' || name.trim() === '') {
16
+ throw new Error('User name is required');
17
+ }
18
+ }
19
+
20
+ async list() {
21
+ return await this.request.execute('GET', '/users');
22
+ }
23
+
24
+ async get(name) {
25
+ this._validateName(name);
26
+ return await this.request.execute('GET', `/users/${encodeURIComponent(name)}`);
27
+ }
28
+
29
+ async create(params) {
30
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
31
+ throw new Error('User parameters must be an object');
32
+ }
33
+ this._validateName(params.name);
34
+ return await this.request.execute('POST', '/users', params);
35
+ }
36
+
37
+ async update(name, params) {
38
+ this._validateName(name);
39
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
40
+ throw new Error('User parameters must be an object');
41
+ }
42
+ return await this.request.execute('PUT', `/users/${encodeURIComponent(name)}`, params);
43
+ }
44
+
45
+ async delete(name) {
46
+ this._validateName(name);
47
+ return await this.request.execute('DELETE', `/users/${encodeURIComponent(name)}`);
48
+ }
49
+ }
50
+
51
+ module.exports = Users;