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.
@@ -14,6 +14,8 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
14
14
  { path: '/stats', methods: ['GET'], status: 'supported', client: 'system.stats' },
15
15
  { path: '/metrics', methods: ['GET'], status: 'supported', client: 'system.metrics' },
16
16
  { path: '/metrics.json', methods: ['GET'], status: 'supported', client: 'system.metricsJson' },
17
+ { path: '/metrics/history', methods: ['GET'], status: 'supported', client: 'system.metricsHistory' },
18
+ { path: '/metrics-history', methods: ['GET'], status: 'supported', client: 'system.metricsHistoryAlias' },
17
19
  { path: '/connections', methods: ['GET'], status: 'supported', client: 'system.connections' },
18
20
  { path: '/rocksdb', methods: ['GET'], status: 'supported', client: 'system.rocksdb' },
19
21
  { path: '/_rocksdb', methods: ['GET'], status: 'supported', client: 'system.rocksdbInternal' },
@@ -35,10 +37,16 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
35
37
  { path: '/collections/distributed', methods: ['GET'], status: 'supported', client: 'client.listCollectionsDistributed' },
36
38
  { path: '/collections', methods: ['GET', 'POST'], status: 'supported', client: 'collections.list/create' },
37
39
  { path: '/collections/{name}', methods: ['GET', 'DELETE'], status: 'supported', client: 'collections.get/delete' },
40
+ { path: '/collections/{name}/lang', methods: ['GET'], status: 'supported', client: 'collections.language' },
41
+ { path: '/collections/{name}/aliases', methods: ['GET'], status: 'supported', client: 'aliases.listCollection' },
38
42
  { path: '/collections/{name}/update', methods: ['POST'], status: 'supported', client: 'collections.update' },
39
43
  { path: '/collections/{name}/documents', methods: ['GET', 'POST', 'DELETE'], status: 'supported', client: 'documents.list/add/deleteByFilter' },
40
44
  { path: '/collections/{name}/documents/import', methods: ['POST'], status: 'supported', client: 'documents.import' },
41
45
  { path: '/collections/{name}/documents/{id}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'documents.get/update/delete' },
46
+ { path: '/collections/{name}/documents/{id}/context', methods: ['GET'], status: 'supported', client: 'documents.context' },
47
+ { path: '/collections/{name}/documents/maybe', methods: ['GET', 'POST'], status: 'supported', client: 'documents.maybe' },
48
+ { path: '/collections/{name}/documents/_update_by_query', methods: ['POST'], status: 'supported', client: 'documents.updateByQuery' },
49
+ { path: '/collections/{name}/documents/_delete_by_query', methods: ['POST'], status: 'supported', client: 'documents.deleteByQuery' },
42
50
  { path: '/collections/{name}/search', methods: ['GET', 'POST'], status: 'supported', client: 'search.search' },
43
51
  { path: '/collections/{name}/documents/search', methods: ['GET', 'POST'], status: 'supported', client: 'search.searchLegacy' },
44
52
  { path: '/collections/{name}/vector_search', methods: ['GET', 'POST'], status: 'supported', client: 'search.vectorSearch' },
@@ -51,8 +59,11 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
51
59
  { path: '/sam/debug', methods: ['GET'], status: 'supported', client: 'sam.debug' },
52
60
  { path: '/sam/history', methods: ['GET'], status: 'supported', client: 'sam.history' },
53
61
  { path: '/sam/pause', methods: ['POST'], status: 'supported', client: 'sam.pause/clearPause' },
62
+ { path: '/sam/improve', methods: ['POST'], status: 'supported', client: 'sam.improve' },
63
+ { path: '/sam/flush_actor_metadata', methods: ['POST'], status: 'supported', client: 'sam.flushActorMetadata' },
54
64
  { path: '/sam/documents', methods: ['GET'], status: 'supported', client: 'sam.listDocuments' },
55
65
  { path: '/sam/documents/{collection}/{id}', methods: ['GET'], status: 'supported', client: 'sam.getDocument/openDocument' },
66
+ { path: '/sam/label/add/{collection}/{id}', methods: ['POST'], status: 'supported', client: 'sam.addDocumentLabel' },
56
67
  { path: '/collections/{name}/documents/facet_counts', methods: ['GET', 'POST'], status: 'supported', client: 'documents.facetCounts' },
57
68
  { path: '/collections/{name}/documents/export', methods: ['GET', 'POST'], status: 'supported', client: 'documents.export' },
58
69
  { path: '/collections/{name}/synonyms', methods: ['GET'], status: 'supported', client: 'synonyms.list' },
@@ -71,15 +82,25 @@ const NODE_CLIENT_ROUTE_COVERAGE = [
71
82
  { path: '/aliases/{name}', methods: ['GET', 'POST', 'PUT', 'DELETE'], status: 'supported', client: 'aliases.get/create/update/delete' },
72
83
  { path: '/keys', methods: ['GET', 'POST'], status: 'supported', client: 'keys.list/create' },
73
84
  { path: '/keys/{id}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'keys.get/update/delete' },
74
- { path: '/update-counters', methods: ['GET', 'POST'], status: 'omitted', reason: 'Internal maintenance endpoint; intentionally left off the public Node client.' },
85
+ { path: '/users', methods: ['GET', 'POST'], status: 'supported', client: 'users.list/create' },
86
+ { path: '/users/{name}', methods: ['GET', 'PUT', 'DELETE'], status: 'supported', client: 'users.get/update/delete' },
87
+ { path: '/analytics/click', methods: ['POST'], status: 'supported', client: 'analytics.click' },
88
+ { path: '/update-counters', methods: ['GET', 'POST'], status: 'supported', client: 'system.updateCounters' },
89
+ { path: '/debug/counters', methods: ['GET'], status: 'supported', client: 'system.debugCounters' },
75
90
  { path: '/query', methods: ['GET'], status: 'omitted', reason: 'Alias of /status; client exposes status() instead of duplicate naming.' },
76
- { path: '/search-config', methods: ['GET'], status: 'omitted', reason: 'Diagnostic configuration endpoint; stable client exposes raw executeRequest() for this route.' },
77
- { path: '/ready', methods: ['GET'], status: 'omitted', reason: 'Readiness alias; client exposes startup() and bootStatus() instead of duplicate naming.' },
78
- { path: '/llm', methods: ['GET', 'POST'], status: 'omitted', reason: 'LLM runtime endpoint is module/config dependent and not part of the stable SDK surface yet.' },
79
- { path: '/repair', methods: ['POST'], status: 'omitted', reason: 'Administrative repair endpoint; intentionally left off the public Node client.' },
80
- { path: '/modules', methods: ['GET'], status: 'omitted', reason: 'Module APIs are intentionally omitted until the module surface is documented and stabilized for SDKs.' },
81
- { path: '/modules/{name}', methods: ['ANY'], status: 'omitted', reason: 'Module-specific routes are dynamic and intentionally not wrapped in the stable Node client.' },
82
- { path: '/modules/{name}/syntax', methods: ['GET'], status: 'omitted', reason: 'Module-specific routes are dynamic and intentionally not wrapped in the stable Node client.' }
91
+ { path: '/search-config', methods: ['GET'], status: 'supported', client: 'system.searchConfig' },
92
+ { path: '/ready', methods: ['GET'], status: 'supported', client: 'system.ready' },
93
+ { path: '/llm', methods: ['GET'], status: 'supported', client: 'system.llm' },
94
+ { path: '/repair', methods: ['GET', 'POST'], status: 'supported', client: 'system.repair' },
95
+ { path: '/modules', methods: ['GET'], status: 'supported', client: 'modules.list' },
96
+ { path: '/modules/load', methods: ['POST'], status: 'omitted', reason: 'Prefix alias; modules.load(name) uses the explicit /loadmodule/{name} form.' },
97
+ { path: '/modules/unload', methods: ['POST'], status: 'omitted', reason: 'Prefix alias; modules.unload(name) uses the explicit /unloadmodule/{name} form.' },
98
+ { path: '/loadmodule', methods: ['POST'], status: 'omitted', reason: 'Body-based alias; modules.load(name) uses the explicit /loadmodule/{name} form.' },
99
+ { path: '/loadmodule/{name}', methods: ['POST'], status: 'supported', client: 'modules.load' },
100
+ { path: '/unloadmodule', methods: ['POST'], status: 'omitted', reason: 'Body-based alias; modules.unload(name) uses the explicit /unloadmodule/{name} form.' },
101
+ { path: '/unloadmodule/{name}', methods: ['POST'], status: 'supported', client: 'modules.unload' },
102
+ { path: '/modules/{name}', methods: ['ANY'], status: 'supported', client: 'modules.call' },
103
+ { path: '/modules/{name}/syntax', methods: ['GET'], status: 'supported', client: 'modules.syntax' }
83
104
  ];
84
105
 
85
106
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlquery-node-client",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Node.js client library for hlquery search engine",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -5,8 +5,17 @@ const http = require('http');
5
5
 
6
6
  const Hlquery = require('..');
7
7
  const Client = require('../lib/Client');
8
+ const Collections = require('../lib/Collections');
9
+ const Documents = require('../lib/Documents');
10
+ const Aliases = require('../lib/Aliases');
8
11
  const Request = require('../lib/Request');
9
12
  const Response = require('../lib/Response');
13
+ const SAM = require('../lib/SAM');
14
+ const Search = require('../lib/Search');
15
+ const System = require('../lib/System');
16
+ const Modules = require('../lib/Modules');
17
+ const Users = require('../lib/Users');
18
+ const Analytics = require('../lib/Analytics');
10
19
  const Config = require('../utils/Config');
11
20
  const Validator = require('../utils/Validator');
12
21
  const CSVParser = require('../utils/CSVParser');
@@ -16,23 +25,35 @@ async function main() {
16
25
  assert.strictEqual(Hlquery.Client, Client, 'named Client export should match default export');
17
26
 
18
27
  assert.strictEqual(Config.normalizeUrl('localhost:9200/'), 'http://localhost:9200');
28
+ assert.strictEqual(Config.normalizeUrl(new URL('http://localhost:9200///')), 'http://localhost:9200');
19
29
  assert.strictEqual(Config.isValidUrl('http://localhost:9200'), true);
30
+ assert.strictEqual(Config.isValidUrl('http://'), false);
20
31
  assert.strictEqual(Config.isValidUrl('notaurl'), false);
21
32
 
22
33
  Validator.validateCollectionName('books');
23
34
  Validator.validateDocumentId('doc_123');
35
+ Validator.validateDocumentId('doc/with space+plus');
24
36
  Validator.validateSearchParams({ limit: 10, offset: 0 });
37
+ Validator.validateDocumentFields({ title: 'good,value' });
25
38
 
26
39
  assert.throws(() => Validator.validateCollectionName('123bad'), /letter or underscore/);
27
40
  assert.throws(() => Validator.validateSearchParams(null), /object/);
28
41
  assert.throws(() => Validator.validateSearchParams({ limit: NaN }), /positive integer/);
29
42
  assert.throws(() => Validator.validateSearchParams({ offset: 1.5 }), /non-negative integer/);
30
- assert.throws(() => Validator.validateDocumentFields({ title: 'bad,value' }), /comma \(\,\)/);
43
+ assert.throws(() => Validator.validateDocumentId('bad\nid'), /control characters/);
44
+ assert.throws(() => Validator.validateDocumentFields({ '': 'bad field' }), /field names/);
31
45
  assert.throws(() => CSVParser.parseCSV('a||b', '||'), /single character/);
32
46
 
33
- const response = new Response(200, { ok: true }, { 'content-type': 'application/json' });
47
+ const response = new Response(200, { ok: true }, { 'content-type': 'application/json' }, {
48
+ statusMessage: 'OK',
49
+ rawBody: Buffer.from('{"ok":true}'),
50
+ request: { method: 'GET', url: 'http://localhost/health' }
51
+ });
34
52
  assert.strictEqual(response.isSuccess(), true);
35
53
  assert.deepStrictEqual(response.getBody(), { ok: true });
54
+ assert.strictEqual(response.getStatusMessage(), 'OK');
55
+ assert.strictEqual(response.getContentType(), 'application/json');
56
+ assert.strictEqual(response.getRawBody().toString(), '{"ok":true}');
36
57
 
37
58
  const server = http.createServer((req, res) => {
38
59
  if (req.url === '/health') {
@@ -53,6 +74,36 @@ async function main() {
53
74
  return;
54
75
  }
55
76
 
77
+ if (req.url === '/modules') {
78
+ res.writeHead(200, { 'Content-Type': 'application/json' });
79
+ res.end(JSON.stringify({ modules: ['demo'] }));
80
+ return;
81
+ }
82
+
83
+ if (req.url === '/modules/demo/syntax') {
84
+ res.writeHead(200, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify({ name: 'demo', syntax: 'demo <q>' }));
86
+ return;
87
+ }
88
+
89
+ if (req.url.startsWith('/modules/demo/search') && req.method === 'GET') {
90
+ res.writeHead(200, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ module: 'demo', route: 'search', url: req.url }));
92
+ return;
93
+ }
94
+
95
+ if (req.url === '/modules/demo/index' && req.method === 'POST') {
96
+ let body = '';
97
+ req.on('data', chunk => {
98
+ body += chunk.toString();
99
+ });
100
+ req.on('end', () => {
101
+ res.writeHead(200, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ indexed: JSON.parse(body) }));
103
+ });
104
+ return;
105
+ }
106
+
56
107
  if (req.url === '/links') {
57
108
  res.writeHead(200, { 'Content-Type': 'application/json' });
58
109
  res.end(JSON.stringify({ links: [] }));
@@ -77,6 +128,26 @@ async function main() {
77
128
  return;
78
129
  }
79
130
 
131
+ if (req.url === '/slow') {
132
+ setTimeout(() => {
133
+ res.writeHead(200, { 'Content-Type': 'application/json' });
134
+ res.end(JSON.stringify({ done: true }));
135
+ }, 100);
136
+ return;
137
+ }
138
+
139
+ if (req.url === '/large') {
140
+ res.writeHead(200, { 'Content-Type': 'application/json' });
141
+ res.end(JSON.stringify({ data: 'x'.repeat(128) }));
142
+ return;
143
+ }
144
+
145
+ if (req.url === '/plain') {
146
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
147
+ res.end('plain response');
148
+ return;
149
+ }
150
+
80
151
  if (req.url === '/flush' && req.method === 'POST') {
81
152
  res.writeHead(200, { 'Content-Type': 'application/json' });
82
153
  res.end(JSON.stringify({ flushed: true }));
@@ -134,6 +205,45 @@ async function main() {
134
205
  assert.match(queryEcho.getBody().url, /filter=%7B%22published%22%3Atrue%7D/);
135
206
  assert.doesNotMatch(queryEcho.getBody().url, /skip=/);
136
207
 
208
+ const repeatedQueryEcho = await request.execute('GET', '/echo-auth', undefined, {
209
+ fields: ['title', 'body']
210
+ }, {
211
+ query_array_format: 'repeat'
212
+ });
213
+ assert.match(repeatedQueryEcho.getBody().url, /fields=title&fields=body/);
214
+
215
+ const plain = await request.execute('GET', '/plain');
216
+ assert.strictEqual(plain.isParsed(), false);
217
+ assert.strictEqual(plain.getBody(), 'plain response');
218
+ assert.strictEqual(plain.getRawBody().toString(), 'plain response');
219
+ assert.match(plain.getRequest().url, /\/plain$/);
220
+
221
+ await assert.rejects(
222
+ () => request.execute('GET', '/large', null, {}, { max_response_bytes: 32 }),
223
+ (error) => error.code === 'MAX_RESPONSE_BYTES' && error.statusCode === 200
224
+ );
225
+
226
+ await assert.rejects(
227
+ () => request.execute('GET', '/missing', null, {}, { throw_on_error: true }),
228
+ (error) => error.statusCode === 404 && error.response && error.response.getError() === 'not found'
229
+ );
230
+
231
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
232
+ if (controller) {
233
+ const aborted = request.execute('GET', '/slow', null, {}, { signal: controller.signal });
234
+ controller.abort();
235
+ await assert.rejects(aborted, (error) => error.code === 'ABORT_ERR');
236
+ }
237
+
238
+ const keepAliveAgent = new http.Agent({ keepAlive: true });
239
+ const agentRequest = new Request(`http://127.0.0.1:${port}`, {
240
+ http_agent: keepAliveAgent,
241
+ headers: { 'X-Test-Default': '1' }
242
+ });
243
+ const agentEcho = await agentRequest.execute('GET', '/echo-auth');
244
+ assert.strictEqual(agentEcho.getStatusCode(), 200);
245
+ keepAliveAgent.destroy();
246
+
137
247
  const client = new Client(`http://127.0.0.1:${port}`);
138
248
  const clientHealth = await client.health();
139
249
  assert.strictEqual(clientHealth.isSuccess(), true);
@@ -145,6 +255,32 @@ async function main() {
145
255
  const clientEtc = await client.etc();
146
256
  assert.deepStrictEqual(clientEtc.getBody(), { protocol: 'http' });
147
257
 
258
+ const routeEtc = await client.route('etc').get();
259
+ assert.deepStrictEqual(routeEtc.body, { protocol: 'http' });
260
+
261
+ const modulesList = await client.modules().list();
262
+ assert.deepStrictEqual(modulesList.body, { modules: ['demo'] });
263
+
264
+ const moduleSyntax = await client.modules().syntax('demo');
265
+ assert.deepStrictEqual(moduleSyntax.body, { name: 'demo', syntax: 'demo <q>' });
266
+
267
+ const moduleSearch = await client.module('demo').route('search').get({
268
+ q: 'example query'
269
+ });
270
+ assert.strictEqual(moduleSearch.body.module, 'demo');
271
+ assert.strictEqual(moduleSearch.body.route, 'search');
272
+ assert.match(moduleSearch.body.url, /q=example\+query|q=example%20query/);
273
+
274
+ const modulePost = await client.module('demo').route('index').post({
275
+ id: 'doc_1'
276
+ });
277
+ assert.deepStrictEqual(modulePost.body.indexed, { id: 'doc_1' });
278
+
279
+ const moduleCall = await client.modules().call('demo', 'search', 'GET', null, {
280
+ q: 'from call'
281
+ });
282
+ assert.match(moduleCall.body.url, /q=from\+call|q=from%20call/);
283
+
148
284
  const clientLinks = await client.links();
149
285
  assert.deepStrictEqual(clientLinks.getBody(), { links: [] });
150
286
 
@@ -172,15 +308,140 @@ async function main() {
172
308
  assert.strictEqual(typeof client.sam().history, 'function');
173
309
  assert.strictEqual(typeof client.sam().pause, 'function');
174
310
  assert.strictEqual(typeof client.sam().clearPause, 'function');
311
+ assert.strictEqual(typeof client.sam().improve, 'function');
312
+ assert.strictEqual(typeof client.sam().flushActorMetadata, 'function');
175
313
  assert.strictEqual(typeof client.sam().listDocuments, 'function');
176
314
  assert.strictEqual(typeof client.sam().getDocument, 'function');
177
315
  assert.strictEqual(typeof client.sam().openDocument, 'function');
316
+ assert.strictEqual(typeof client.sam().addDocumentLabel, 'function');
317
+ assert.strictEqual(typeof client.users().list, 'function');
318
+ assert.strictEqual(typeof client.analytics().click, 'function');
178
319
  await assert.rejects(() => client.sam().history(null, 0), /positive integer/);
179
320
  await assert.rejects(() => client.sam().listDocuments('books', NaN, 20), /non-negative integer/);
180
321
 
181
322
  const keyResponse = await client.keys().get('key/with space');
182
323
  assert.deepStrictEqual(keyResponse.getBody(), { id: 'key/with space' });
183
324
  await assert.rejects(() => client.documents().import('books', { id: 'not-array' }), /array/);
325
+
326
+ const strictClient = new Client(`http://127.0.0.1:${port}`, { throw_on_error: true });
327
+ await assert.rejects(
328
+ () => strictClient.executeRequest('GET', '/missing'),
329
+ (error) => error.statusCode === 404 && error.response.getStatusCode() === 404
330
+ );
331
+
332
+ const fakeCollections = new Collections({
333
+ async execute(method, path, body) {
334
+ assert.strictEqual(body.name, 'books');
335
+ assert.strictEqual(body.extra, true);
336
+ return new Response(201, body);
337
+ }
338
+ });
339
+ const created = await fakeCollections.create('books', { name: 'books', extra: true });
340
+ assert.strictEqual(created.getBody().name, 'books');
341
+ await assert.rejects(() => fakeCollections.create('books', { name: 'other' }), /must match/);
342
+
343
+ const fieldsApi = new Collections({
344
+ async execute() {
345
+ return new Response(200, {
346
+ fields: [{ name: 'title', type: 'string' }],
347
+ searchable_fields: ['body'],
348
+ filterable_fields: ['author']
349
+ });
350
+ }
351
+ });
352
+ const fields = await fieldsApi.getFields('books');
353
+ assert.deepStrictEqual(fields.getBody().fields.map(field => field.name), ['title', 'body', 'author']);
354
+
355
+ const capturedRequests = [];
356
+ const captureRequest = {
357
+ async execute(method, path, body = null, queryParams = {}) {
358
+ capturedRequests.push({ method, path, body, queryParams });
359
+ return new Response(200, { ok: true });
360
+ }
361
+ };
362
+ const capturedCollections = new Collections(captureRequest);
363
+ const capturedDocuments = new Documents(captureRequest);
364
+ const capturedAliases = new Aliases(captureRequest);
365
+ const capturedSam = new SAM(captureRequest);
366
+ const capturedSystem = new System(captureRequest);
367
+ const capturedModules = new Modules(captureRequest);
368
+ const capturedUsers = new Users(captureRequest);
369
+ const capturedAnalytics = new Analytics(captureRequest);
370
+
371
+ await capturedCollections.language('books');
372
+ await capturedAliases.listCollection('books');
373
+ await capturedDocuments.context('books', 'doc/one');
374
+ await capturedDocuments.maybe('books', { q: 'bok' });
375
+ await capturedDocuments.updateByQuery('books', { filter_by: 'author:alice', update: { featured: true } });
376
+ await capturedDocuments.deleteByQuery('books', { filter_by: 'author:alice' });
377
+ await capturedSam.improve({ limit: 2, force: true });
378
+ await capturedSam.flushActorMetadata();
379
+ await capturedSam.addDocumentLabel('books', 'doc/one', ['featured']);
380
+ await capturedUsers.list();
381
+ await capturedUsers.get('alice smith');
382
+ await capturedUsers.create({ name: 'alice smith', flags: ['user'] });
383
+ await capturedUsers.update('alice smith', { description: 'Updated' });
384
+ await capturedUsers.delete('alice smith');
385
+ await capturedAnalytics.click({ collection: 'books', doc_id: 'doc/one', query: 'book', rank: 1 });
386
+ await capturedSystem.ready();
387
+ await capturedSystem.metricsHistory();
388
+ await capturedSystem.metricsHistoryAlias();
389
+ await capturedSystem.searchConfig();
390
+ await capturedSystem.llm();
391
+ await capturedSystem.updateCounters({ prefix: 'bench_' });
392
+ await capturedSystem.debugCounters();
393
+ await capturedSystem.repair({ collection: 'books', rebuild_index: true });
394
+ await capturedModules.load('demo');
395
+ await capturedModules.unload('demo');
396
+
397
+ assert.deepStrictEqual(capturedRequests, [
398
+ { method: 'GET', path: '/collections/books/lang', body: null, queryParams: {} },
399
+ { method: 'GET', path: '/collections/books/aliases', body: null, queryParams: {} },
400
+ { method: 'GET', path: '/collections/books/documents/doc%2Fone/context', body: null, queryParams: {} },
401
+ { method: 'GET', path: '/collections/books/documents/maybe', body: null, queryParams: { q: 'bok' } },
402
+ { method: 'POST', path: '/collections/books/documents/_update_by_query', body: { filter_by: 'author:alice', update: { featured: true } }, queryParams: {} },
403
+ { method: 'POST', path: '/collections/books/documents/_delete_by_query', body: { filter_by: 'author:alice' }, queryParams: {} },
404
+ { method: 'POST', path: '/sam/improve', body: null, queryParams: { limit: 2, force: true } },
405
+ { method: 'POST', path: '/sam/flush_actor_metadata', body: null, queryParams: {} },
406
+ { method: 'POST', path: '/sam/label/add/books/doc%2Fone', body: { labels: ['featured'] }, queryParams: {} },
407
+ { method: 'GET', path: '/users', body: null, queryParams: {} },
408
+ { method: 'GET', path: '/users/alice%20smith', body: null, queryParams: {} },
409
+ { method: 'POST', path: '/users', body: { name: 'alice smith', flags: ['user'] }, queryParams: {} },
410
+ { method: 'PUT', path: '/users/alice%20smith', body: { description: 'Updated' }, queryParams: {} },
411
+ { method: 'DELETE', path: '/users/alice%20smith', body: null, queryParams: {} },
412
+ { method: 'POST', path: '/analytics/click', body: { collection: 'books', doc_id: 'doc/one', query: 'book', rank: 1 }, queryParams: {} },
413
+ { method: 'GET', path: '/ready', body: null, queryParams: {} },
414
+ { method: 'GET', path: '/metrics/history', body: null, queryParams: {} },
415
+ { method: 'GET', path: '/metrics-history', body: null, queryParams: {} },
416
+ { method: 'GET', path: '/search-config', body: null, queryParams: {} },
417
+ { method: 'GET', path: '/llm', body: null, queryParams: {} },
418
+ { method: 'POST', path: '/update-counters', body: null, queryParams: { prefix: 'bench_' } },
419
+ { method: 'GET', path: '/debug/counters', body: null, queryParams: {} },
420
+ { method: 'POST', path: '/repair', body: null, queryParams: { collection: 'books', rebuild_index: true } },
421
+ { method: 'POST', path: '/loadmodule/demo', body: null, queryParams: {} },
422
+ { method: 'POST', path: '/unloadmodule/demo', body: null, queryParams: {} }
423
+ ]);
424
+
425
+ let collectionFetches = 0;
426
+ const searchApi = new Search({
427
+ async execute(method, path, body, queryParams) {
428
+ return new Response(200, { queryParams });
429
+ }
430
+ }, {
431
+ async get() {
432
+ collectionFetches += 1;
433
+ return new Response(200, { searchable_fields: ['title', 'body'] });
434
+ }
435
+ });
436
+ await searchApi.search('books', { q: 'one' });
437
+ await searchApi.search('books', { q: 'two' });
438
+ assert.strictEqual(collectionFetches, 1);
439
+
440
+ const noAutoDetect = await searchApi.search('books', {
441
+ q: 'three',
442
+ auto_detect_query_by: false
443
+ });
444
+ assert.strictEqual(noAutoDetect.getBody().queryParams.query_by, undefined);
184
445
  } finally {
185
446
  await new Promise((resolve) => server.close(resolve));
186
447
  }
package/utils/Config.js CHANGED
@@ -23,6 +23,14 @@ class Config {
23
23
  timeout: options.timeout || 30000,
24
24
  token: options.token || null,
25
25
  auth_method: options.auth_method || 'bearer',
26
+ agent: options.agent || null,
27
+ http_agent: options.http_agent || null,
28
+ https_agent: options.https_agent || null,
29
+ headers: options.headers || {},
30
+ max_response_bytes: options.max_response_bytes || 0,
31
+ throw_on_error: options.throw_on_error || false,
32
+ query_array_format: options.query_array_format || 'comma',
33
+ signal: options.signal || null,
26
34
  ...options
27
35
  };
28
36
  }
@@ -34,11 +42,17 @@ class Config {
34
42
  if (!url) {
35
43
  return Config.getDefaultBaseUrl();
36
44
  }
45
+ if (url instanceof URL) {
46
+ url = url.toString();
47
+ }
48
+ if (typeof url !== 'string') {
49
+ url = String(url);
50
+ }
37
51
  url = url.trim();
38
52
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
39
53
  url = 'http://' + url;
40
54
  }
41
- return url.replace(/\/$/, '');
55
+ return url.replace(/\/+$/, '');
42
56
  }
43
57
 
44
58
  /**
@@ -47,7 +61,8 @@ class Config {
47
61
  static isValidUrl(url) {
48
62
  try {
49
63
  const urlObj = new URL(url);
50
- return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
64
+ return (urlObj.protocol === 'http:' || urlObj.protocol === 'https:') &&
65
+ Boolean(urlObj.hostname);
51
66
  } catch (e) {
52
67
  return false;
53
68
  }
@@ -56,12 +56,12 @@ class Validator {
56
56
  throw new ValidationException('Document ID must be between 1 and 64 characters');
57
57
  }
58
58
 
59
- // Document IDs should be URL-safe: alphanumeric, underscores, and hyphens only
60
- if (!/^[a-zA-Z0-9_-]+$/.test(idStr)) {
61
- throw new ValidationException(
62
- 'Document ID contains invalid characters. ' +
63
- 'Use only letters, numbers, underscores, and hyphens'
64
- );
59
+ if (idStr.trim() === '') {
60
+ throw new ValidationException('Document ID must be a non-empty string or number');
61
+ }
62
+
63
+ if (/[\x00-\x1F\x7F]/.test(idStr)) {
64
+ throw new ValidationException('Document ID contains control characters');
65
65
  }
66
66
  }
67
67
 
@@ -100,8 +100,7 @@ class Validator {
100
100
  }
101
101
 
102
102
  /**
103
- * Validate document field values for invalid characters
104
- * Commas are not allowed in string field values as they're reserved for internal parsing
103
+ * Validate document field values.
105
104
  *
106
105
  * @param {object} document - Document object to validate
107
106
  * @throws {ValidationException} If document contains invalid characters
@@ -111,28 +110,12 @@ class Validator {
111
110
  return; // Skip validation for non-objects or arrays (will be validated per-item)
112
111
  }
113
112
 
114
- for (const [key, value] of Object.entries(document)) {
115
- // Skip the 'id' field as it has its own validation
116
- if (key === 'id') continue;
117
-
118
- // Check string values for commas
119
- if (typeof value === 'string' && value.includes(',')) {
120
- throw new ValidationException(
121
- `Field '${key}' contains invalid character: comma (,). ` +
122
- `Commas are not allowed in field values. Use underscores (_) or spaces instead, or use arrays for multiple values.`
123
- );
113
+ for (const key of Object.keys(document)) {
114
+ if (key === '') {
115
+ throw new ValidationException('Document field names must be non-empty strings');
124
116
  }
125
-
126
- // Check array values - ensure they don't contain strings with commas
127
- if (Array.isArray(value)) {
128
- for (const item of value) {
129
- if (typeof item === 'string' && item.includes(',')) {
130
- throw new ValidationException(
131
- `Field '${key}' contains invalid character: comma (,). ` +
132
- `Array items cannot contain commas. Use underscores (_) or spaces instead.`
133
- );
134
- }
135
- }
117
+ if (/[\x00-\x1F\x7F]/.test(key)) {
118
+ throw new ValidationException(`Field '${key}' contains control characters`);
136
119
  }
137
120
  }
138
121
  }