@nixxie-cms/search 1.0.1

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.
@@ -0,0 +1,433 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var _defineProperty = require('@babel/runtime/helpers/defineProperty');
6
+
7
+ /** Algolia backend over the REST API (no SDK dependency). */
8
+ class AlgoliaSearch {
9
+ constructor(config) {
10
+ this.appId = config.appId;
11
+ this.apiKey = config.apiKey;
12
+ }
13
+ headers() {
14
+ return {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Algolia-API-Key': this.apiKey,
17
+ 'X-Algolia-Application-Id': this.appId
18
+ };
19
+ }
20
+ writeHost() {
21
+ return `https://${this.appId}.algolia.net`;
22
+ }
23
+ readHost() {
24
+ return `https://${this.appId}-dsn.algolia.net`;
25
+ }
26
+ async index(indexName, documents) {
27
+ const docs = Array.isArray(documents) ? documents : [documents];
28
+ const requests = docs.map(d => ({
29
+ action: 'updateObject',
30
+ body: {
31
+ ...d,
32
+ objectID: d.id
33
+ }
34
+ }));
35
+ const res = await fetch(`${this.writeHost()}/1/indexes/${indexName}/batch`, {
36
+ method: 'POST',
37
+ headers: this.headers(),
38
+ body: JSON.stringify({
39
+ requests
40
+ })
41
+ });
42
+ if (!res.ok) throw new Error(`Algolia index failed (${res.status}): ${await res.text()}`);
43
+ }
44
+ async remove(indexName, id) {
45
+ await fetch(`${this.writeHost()}/1/indexes/${indexName}/${encodeURIComponent(id)}`, {
46
+ method: 'DELETE',
47
+ headers: this.headers()
48
+ });
49
+ }
50
+ async search(indexName, query) {
51
+ var _query$offset, _query$limit, _data$hits, _ref, _data$nbHits, _data$hits2;
52
+ const filters = query.filter ? Object.entries(query.filter).map(([k, v]) => `${k}:${JSON.stringify(v)}`).join(' AND ') : undefined;
53
+ const res = await fetch(`${this.readHost()}/1/indexes/${indexName}/query`, {
54
+ method: 'POST',
55
+ headers: this.headers(),
56
+ // Algolia's two pagination modes are mutually exclusive: page+hitsPerPage OR offset+length.
57
+ // Mixing them returns HTTP 400, so use offset/length only.
58
+ body: JSON.stringify({
59
+ query: query.q,
60
+ offset: (_query$offset = query.offset) !== null && _query$offset !== void 0 ? _query$offset : 0,
61
+ length: (_query$limit = query.limit) !== null && _query$limit !== void 0 ? _query$limit : 20,
62
+ filters
63
+ })
64
+ });
65
+ if (!res.ok) throw new Error(`Algolia search failed (${res.status}): ${await res.text()}`);
66
+ const data = await res.json();
67
+ return {
68
+ hits: ((_data$hits = data.hits) !== null && _data$hits !== void 0 ? _data$hits : []).map(h => {
69
+ var _h$_rankingInfo;
70
+ return {
71
+ document: {
72
+ ...h,
73
+ id: h.objectID
74
+ },
75
+ score: (_h$_rankingInfo = h._rankingInfo) === null || _h$_rankingInfo === void 0 ? void 0 : _h$_rankingInfo.userScore
76
+ };
77
+ }),
78
+ total: (_ref = (_data$nbHits = data.nbHits) !== null && _data$nbHits !== void 0 ? _data$nbHits : (_data$hits2 = data.hits) === null || _data$hits2 === void 0 ? void 0 : _data$hits2.length) !== null && _ref !== void 0 ? _ref : 0,
79
+ tookMs: data.processingTimeMS
80
+ };
81
+ }
82
+ async clear(indexName) {
83
+ await fetch(`${this.writeHost()}/1/indexes/${indexName}/clear`, {
84
+ method: 'POST',
85
+ headers: this.headers()
86
+ });
87
+ }
88
+ async close() {}
89
+ }
90
+
91
+ /** Elasticsearch backend over the REST API (no SDK dependency). */
92
+ class ElasticsearchSearch {
93
+ constructor(config) {
94
+ this.node = config.node.replace(/\/$/, '');
95
+ if (config.apiKey) this.authHeader = `ApiKey ${config.apiKey}`;else if (config.username && config.password) {
96
+ this.authHeader = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
97
+ }
98
+ }
99
+ async request(path, method, body) {
100
+ const res = await fetch(`${this.node}${path}`, {
101
+ method,
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ ...(this.authHeader ? {
105
+ Authorization: this.authHeader
106
+ } : {})
107
+ },
108
+ body: body === undefined ? undefined : JSON.stringify(body)
109
+ });
110
+ if (!res.ok && res.status !== 404) {
111
+ throw new Error(`Elasticsearch request failed (${res.status}): ${await res.text()}`);
112
+ }
113
+ return res.status === 404 ? undefined : res.json();
114
+ }
115
+ async index(indexName, documents) {
116
+ const docs = Array.isArray(documents) ? documents : [documents];
117
+ const body = docs.map(d => `${JSON.stringify({
118
+ index: {
119
+ _index: indexName,
120
+ _id: d.id
121
+ }
122
+ })}\n${JSON.stringify(d)}`).join('\n') + '\n';
123
+ const res = await fetch(`${this.node}/_bulk?refresh=true`, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/x-ndjson',
127
+ ...(this.authHeader ? {
128
+ Authorization: this.authHeader
129
+ } : {})
130
+ },
131
+ body
132
+ });
133
+ if (!res.ok) throw new Error(`Elasticsearch bulk failed (${res.status}): ${await res.text()}`);
134
+ }
135
+ async remove(indexName, id) {
136
+ await this.request(`/${indexName}/_doc/${encodeURIComponent(id)}?refresh=true`, 'DELETE');
137
+ }
138
+ async search(indexName, query) {
139
+ var _query$sort, _query$offset, _query$limit, _res$hits$hits, _res$hits, _res$hits2, _res$hits$total, _res$hits3;
140
+ const must = [query.q ? {
141
+ multi_match: {
142
+ query: query.q,
143
+ fields: ['*']
144
+ }
145
+ } : {
146
+ match_all: {}
147
+ }];
148
+ const filter = query.filter ? Object.entries(query.filter).map(([k, v]) => ({
149
+ term: {
150
+ [k]: v
151
+ }
152
+ })) : [];
153
+ const sort = (_query$sort = query.sort) === null || _query$sort === void 0 ? void 0 : _query$sort.map(s => {
154
+ const [field, dir = 'asc'] = s.split(':');
155
+ return {
156
+ [field]: dir
157
+ };
158
+ });
159
+ const res = await this.request(`/${indexName}/_search`, 'POST', {
160
+ from: (_query$offset = query.offset) !== null && _query$offset !== void 0 ? _query$offset : 0,
161
+ size: (_query$limit = query.limit) !== null && _query$limit !== void 0 ? _query$limit : 20,
162
+ query: {
163
+ bool: {
164
+ must,
165
+ filter
166
+ }
167
+ },
168
+ sort
169
+ });
170
+ const hits = (_res$hits$hits = res === null || res === void 0 || (_res$hits = res.hits) === null || _res$hits === void 0 ? void 0 : _res$hits.hits) !== null && _res$hits$hits !== void 0 ? _res$hits$hits : [];
171
+ return {
172
+ hits: hits.map(h => ({
173
+ document: {
174
+ ...h._source,
175
+ id: h._id
176
+ },
177
+ score: h._score
178
+ })),
179
+ total: typeof (res === null || res === void 0 || (_res$hits2 = res.hits) === null || _res$hits2 === void 0 ? void 0 : _res$hits2.total) === 'object' ? res.hits.total.value : (_res$hits$total = res === null || res === void 0 || (_res$hits3 = res.hits) === null || _res$hits3 === void 0 ? void 0 : _res$hits3.total) !== null && _res$hits$total !== void 0 ? _res$hits$total : 0,
180
+ tookMs: res === null || res === void 0 ? void 0 : res.took
181
+ };
182
+ }
183
+ async clear(indexName) {
184
+ await this.request(`/${indexName}/_delete_by_query?refresh=true`, 'POST', {
185
+ query: {
186
+ match_all: {}
187
+ }
188
+ });
189
+ }
190
+ async close() {}
191
+ }
192
+
193
+ /**
194
+ * Zero-dependency in-memory search. Tokenises stringified document values and ranks by the
195
+ * number of query terms matched. Intended for development, tests and small datasets — swap in a
196
+ * real engine (Meilisearch/Typesense/Algolia/Elasticsearch) for production.
197
+ */
198
+ class InMemorySearch {
199
+ constructor() {
200
+ _defineProperty(this, "indexes", new Map());
201
+ }
202
+ indexFor(name) {
203
+ let idx = this.indexes.get(name);
204
+ if (!idx) {
205
+ idx = new Map();
206
+ this.indexes.set(name, idx);
207
+ }
208
+ return idx;
209
+ }
210
+ async index(indexName, documents) {
211
+ const idx = this.indexFor(indexName);
212
+ for (const doc of Array.isArray(documents) ? documents : [documents]) {
213
+ if (!doc.id) throw new Error('Search documents must have an `id`');
214
+ idx.set(doc.id, doc);
215
+ }
216
+ }
217
+ async remove(indexName, id) {
218
+ var _this$indexes$get;
219
+ (_this$indexes$get = this.indexes.get(indexName)) === null || _this$indexes$get === void 0 || _this$indexes$get.delete(id);
220
+ }
221
+ async search(indexName, query) {
222
+ var _query$sort, _query$offset, _query$limit;
223
+ const start = Date.now();
224
+ const idx = this.indexes.get(indexName);
225
+ const terms = query.q.toLowerCase().split(/\s+/).filter(Boolean);
226
+ const matched = [];
227
+ for (const doc of (_idx$values = idx === null || idx === void 0 ? void 0 : idx.values()) !== null && _idx$values !== void 0 ? _idx$values : []) {
228
+ var _idx$values;
229
+ if (query.filter && !this.matchesFilter(doc, query.filter)) continue;
230
+ const haystack = JSON.stringify(doc).toLowerCase();
231
+ const score = terms.length === 0 ? 1 : terms.filter(t => haystack.includes(t)).length;
232
+ if (terms.length === 0 || score > 0) matched.push({
233
+ document: doc,
234
+ score
235
+ });
236
+ }
237
+ matched.sort((a, b) => b.score - a.score);
238
+ if ((_query$sort = query.sort) !== null && _query$sort !== void 0 && _query$sort.length) this.applySort(matched, query.sort);
239
+ const offset = (_query$offset = query.offset) !== null && _query$offset !== void 0 ? _query$offset : 0;
240
+ const limit = (_query$limit = query.limit) !== null && _query$limit !== void 0 ? _query$limit : 20;
241
+ const page = matched.slice(offset, offset + limit);
242
+ return {
243
+ hits: page.map(m => ({
244
+ document: m.document,
245
+ score: m.score
246
+ })),
247
+ total: matched.length,
248
+ tookMs: Date.now() - start
249
+ };
250
+ }
251
+ async clear(indexName) {
252
+ var _this$indexes$get2;
253
+ (_this$indexes$get2 = this.indexes.get(indexName)) === null || _this$indexes$get2 === void 0 || _this$indexes$get2.clear();
254
+ }
255
+ async close() {
256
+ this.indexes.clear();
257
+ }
258
+ matchesFilter(doc, filter) {
259
+ return Object.entries(filter).every(([k, v]) => doc[k] === v);
260
+ }
261
+ applySort(items, sort) {
262
+ items.sort((a, b) => {
263
+ for (const spec of sort) {
264
+ const [attr, dir = 'asc'] = spec.split(':');
265
+ const av = a.document[attr];
266
+ const bv = b.document[attr];
267
+ if (av === bv) continue;
268
+ const cmp = av > bv ? 1 : -1;
269
+ return dir === 'desc' ? -cmp : cmp;
270
+ }
271
+ return 0;
272
+ });
273
+ }
274
+ }
275
+
276
+ /** Meilisearch backend over the REST API (no SDK dependency). */
277
+ class MeilisearchSearch {
278
+ constructor(config) {
279
+ this.base = config.url.replace(/\/$/, '');
280
+ this.apiKey = config.apiKey;
281
+ }
282
+ async request(path, method, body) {
283
+ const res = await fetch(`${this.base}${path}`, {
284
+ method,
285
+ headers: {
286
+ 'Content-Type': 'application/json',
287
+ ...(this.apiKey ? {
288
+ Authorization: `Bearer ${this.apiKey}`
289
+ } : {})
290
+ },
291
+ body: body === undefined ? undefined : JSON.stringify(body)
292
+ });
293
+ if (!res.ok && res.status !== 404) {
294
+ throw new Error(`Meilisearch request failed (${res.status}): ${await res.text()}`);
295
+ }
296
+ return res.status === 404 ? undefined : res.json();
297
+ }
298
+ async index(indexName, documents) {
299
+ const docs = Array.isArray(documents) ? documents : [documents];
300
+ await this.request(`/indexes/${indexName}/documents`, 'POST', docs);
301
+ }
302
+ async remove(indexName, id) {
303
+ await this.request(`/indexes/${indexName}/documents/${encodeURIComponent(id)}`, 'DELETE');
304
+ }
305
+ async search(indexName, query) {
306
+ var _query$limit, _query$offset, _res$hits, _ref, _res$estimatedTotalHi, _res$hits2;
307
+ const filter = query.filter ? Object.entries(query.filter).map(([k, v]) => `${k} = ${JSON.stringify(v)}`) : undefined;
308
+ const res = await this.request(`/indexes/${indexName}/search`, 'POST', {
309
+ q: query.q,
310
+ limit: (_query$limit = query.limit) !== null && _query$limit !== void 0 ? _query$limit : 20,
311
+ offset: (_query$offset = query.offset) !== null && _query$offset !== void 0 ? _query$offset : 0,
312
+ filter,
313
+ sort: query.sort
314
+ });
315
+ return {
316
+ hits: ((_res$hits = res === null || res === void 0 ? void 0 : res.hits) !== null && _res$hits !== void 0 ? _res$hits : []).map(document => ({
317
+ document
318
+ })),
319
+ total: (_ref = (_res$estimatedTotalHi = res === null || res === void 0 ? void 0 : res.estimatedTotalHits) !== null && _res$estimatedTotalHi !== void 0 ? _res$estimatedTotalHi : res === null || res === void 0 || (_res$hits2 = res.hits) === null || _res$hits2 === void 0 ? void 0 : _res$hits2.length) !== null && _ref !== void 0 ? _ref : 0,
320
+ tookMs: res === null || res === void 0 ? void 0 : res.processingTimeMs
321
+ };
322
+ }
323
+ async clear(indexName) {
324
+ await this.request(`/indexes/${indexName}/documents`, 'DELETE');
325
+ }
326
+ async close() {}
327
+ }
328
+
329
+ /**
330
+ * Typesense backend over the REST API (no SDK dependency).
331
+ * Collections must already exist; documents are upserted. Typesense has no wildcard `query_by`,
332
+ * so configure `queryBy` to match your collection's searchable fields.
333
+ * Note: `clear()` drops the entire collection (Typesense has no schema-preserving truncate),
334
+ * so recreate the collection before re-indexing.
335
+ */
336
+ class TypesenseSearch {
337
+ constructor(config) {
338
+ var _config$protocol, _config$port, _config$queryBy;
339
+ const protocol = (_config$protocol = config.protocol) !== null && _config$protocol !== void 0 ? _config$protocol : 'http';
340
+ const port = (_config$port = config.port) !== null && _config$port !== void 0 ? _config$port : 8108;
341
+ this.base = `${protocol}://${config.host}:${port}`;
342
+ this.apiKey = config.apiKey;
343
+ this.queryBy = (_config$queryBy = config.queryBy) !== null && _config$queryBy !== void 0 ? _config$queryBy : 'title,name,body,content';
344
+ }
345
+ headers() {
346
+ return {
347
+ 'Content-Type': 'application/json',
348
+ 'X-TYPESENSE-API-KEY': this.apiKey
349
+ };
350
+ }
351
+ async index(indexName, documents) {
352
+ const docs = Array.isArray(documents) ? documents : [documents];
353
+ const body = docs.map(d => JSON.stringify(d)).join('\n');
354
+ const res = await fetch(`${this.base}/collections/${indexName}/documents/import?action=upsert`, {
355
+ method: 'POST',
356
+ headers: this.headers(),
357
+ body
358
+ });
359
+ if (!res.ok) throw new Error(`Typesense import failed (${res.status}): ${await res.text()}`);
360
+ }
361
+ async remove(indexName, id) {
362
+ await fetch(`${this.base}/collections/${indexName}/documents/${encodeURIComponent(id)}`, {
363
+ method: 'DELETE',
364
+ headers: this.headers()
365
+ });
366
+ }
367
+ async search(indexName, query) {
368
+ var _query$limit, _query$offset, _query$sort, _data$hits, _data$found;
369
+ // Use Typesense's `offset`/`limit` params (supported since v0.23) rather than `page`/`per_page`,
370
+ // so arbitrary, non-page-aligned offsets return the correct window.
371
+ const params = new URLSearchParams({
372
+ q: query.q,
373
+ query_by: this.queryBy,
374
+ limit: String((_query$limit = query.limit) !== null && _query$limit !== void 0 ? _query$limit : 20),
375
+ offset: String((_query$offset = query.offset) !== null && _query$offset !== void 0 ? _query$offset : 0)
376
+ });
377
+ if (query.filter) {
378
+ params.set('filter_by', Object.entries(query.filter).map(([k, v]) => `${k}:=${JSON.stringify(v)}`).join(' && '));
379
+ }
380
+ if ((_query$sort = query.sort) !== null && _query$sort !== void 0 && _query$sort.length) params.set('sort_by', query.sort.join(','));
381
+ const res = await fetch(`${this.base}/collections/${indexName}/documents/search?${params}`, {
382
+ headers: this.headers()
383
+ });
384
+ if (!res.ok) throw new Error(`Typesense search failed (${res.status}): ${await res.text()}`);
385
+ const data = await res.json();
386
+ return {
387
+ hits: ((_data$hits = data.hits) !== null && _data$hits !== void 0 ? _data$hits : []).map(h => ({
388
+ document: h.document,
389
+ score: h.text_match
390
+ })),
391
+ total: (_data$found = data.found) !== null && _data$found !== void 0 ? _data$found : 0,
392
+ tookMs: data.search_time_ms
393
+ };
394
+ }
395
+ async clear(indexName) {
396
+ const res = await fetch(`${this.base}/collections/${indexName}`, {
397
+ method: 'DELETE',
398
+ headers: this.headers()
399
+ });
400
+ // A missing collection (404) is already "cleared"; anything else is a real failure.
401
+ if (!res.ok && res.status !== 404) {
402
+ throw new Error(`Typesense clear failed (${res.status}): ${await res.text()}`);
403
+ }
404
+ }
405
+ async close() {}
406
+ }
407
+
408
+ function createSearch(config) {
409
+ switch (config.driver) {
410
+ case 'memory':
411
+ return new InMemorySearch();
412
+ case 'meilisearch':
413
+ return new MeilisearchSearch(config);
414
+ case 'typesense':
415
+ return new TypesenseSearch(config);
416
+ case 'algolia':
417
+ return new AlgoliaSearch(config);
418
+ case 'elasticsearch':
419
+ return new ElasticsearchSearch(config);
420
+ default:
421
+ {
422
+ const exhaustive = config;
423
+ throw new Error(`Unknown search driver: ${exhaustive.driver}`);
424
+ }
425
+ }
426
+ }
427
+
428
+ exports.AlgoliaSearch = AlgoliaSearch;
429
+ exports.ElasticsearchSearch = ElasticsearchSearch;
430
+ exports.InMemorySearch = InMemorySearch;
431
+ exports.MeilisearchSearch = MeilisearchSearch;
432
+ exports.TypesenseSearch = TypesenseSearch;
433
+ exports.createSearch = createSearch;