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