@nebutra/search 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,352 +0,0 @@
1
- import { logger } from "@nebutra/logger";
2
- import Typesense from "typesense";
3
- // =============================================================================
4
- // Typesense Provider — Optimised for geo search and instant faceting
5
- // =============================================================================
6
- export class TypesenseProvider {
7
- name = "typesense";
8
- client;
9
- constructor(config) {
10
- const url = config?.url ?? process.env.TYPESENSE_URL ?? "http://localhost:8108";
11
- const apiKey = config?.apiKey ?? process.env.TYPESENSE_API_KEY ?? "xyz";
12
- const timeout = config?.timeout ?? 30000;
13
- logger.info("[search:typesense] Initializing", { url });
14
- this.client = new Typesense.Client({
15
- nodes: [
16
- {
17
- host: this._parseHostFromUrl(url),
18
- port: this._parsePortFromUrl(url),
19
- protocol: this._parseProtocolFromUrl(url),
20
- },
21
- ],
22
- apiKey,
23
- connectionTimeoutSeconds: timeout / 1000,
24
- });
25
- }
26
- async indexDocument(index, doc) {
27
- try {
28
- const collectionName = this._getCollectionName(index, doc.tenantId);
29
- const document = this._prepareDocument(doc);
30
- await this.client.collections(collectionName).documents().create(document);
31
- logger.debug("[search:typesense] Indexed document", {
32
- collection: collectionName,
33
- docId: doc.id,
34
- });
35
- }
36
- catch (error) {
37
- logger.error("[search:typesense] Failed to index document", {
38
- index,
39
- docId: doc.id,
40
- error: error instanceof Error ? error.message : String(error),
41
- });
42
- throw error;
43
- }
44
- }
45
- async indexDocuments(index, docs) {
46
- if (docs.length === 0)
47
- return;
48
- try {
49
- // Group documents by tenant
50
- const byTenant = new Map();
51
- for (const doc of docs) {
52
- const tenant = doc.tenantId;
53
- if (!byTenant.has(tenant)) {
54
- byTenant.set(tenant, []);
55
- }
56
- byTenant.get(tenant)?.push(doc);
57
- }
58
- // Index each tenant's documents
59
- for (const [tenantId, tenantDocs] of byTenant.entries()) {
60
- const collectionName = this._getCollectionName(index, tenantId);
61
- const documents = tenantDocs.map((doc) => this._prepareDocument(doc));
62
- for (const doc of documents) {
63
- await this.client.collections(collectionName).documents().create(doc);
64
- }
65
- logger.debug("[search:typesense] Indexed batch", {
66
- collection: collectionName,
67
- count: tenantDocs.length,
68
- });
69
- }
70
- }
71
- catch (error) {
72
- logger.error("[search:typesense] Failed to index batch", {
73
- index,
74
- count: docs.length,
75
- error: error instanceof Error ? error.message : String(error),
76
- });
77
- throw error;
78
- }
79
- }
80
- async search(index, query) {
81
- try {
82
- const collectionName = this._getCollectionName(index, query.tenantId);
83
- const page = query.page ?? 1;
84
- const hitsPerPage = Math.min(query.hitsPerPage ?? 20, 100);
85
- // Build filter conditions
86
- let filterBy;
87
- if (query.filters || query.tenantId) {
88
- const filterParts = [];
89
- if (query.tenantId) {
90
- filterParts.push(`tenantId:=${query.tenantId}`);
91
- }
92
- if (query.filters) {
93
- for (const [key, value] of Object.entries(query.filters)) {
94
- if (typeof value === "string") {
95
- filterParts.push(`${key}:=${value}`);
96
- }
97
- else if (typeof value === "number") {
98
- filterParts.push(`${key}:=${value}`);
99
- }
100
- else if (typeof value === "boolean") {
101
- filterParts.push(`${key}:=${value ? 1 : 0}`);
102
- }
103
- }
104
- }
105
- if (filterParts.length > 0) {
106
- filterBy = filterParts.join(" && ");
107
- }
108
- }
109
- const startMs = performance.now();
110
- const searchParams = {
111
- q: query.query,
112
- query_by: this._getQueryByFields(),
113
- page,
114
- per_page: hitsPerPage,
115
- typo_tokens_threshold: query.typoTolerance !== false ? 1 : 0,
116
- };
117
- if (filterBy) {
118
- searchParams.filter_by = filterBy;
119
- }
120
- if (query.facets) {
121
- searchParams.facet_by = query.facets.join(",");
122
- }
123
- if (query.sort) {
124
- searchParams.sort_by = query.sort.join(",");
125
- }
126
- if (query.highlightFields) {
127
- searchParams.highlight_fields = query.highlightFields.join(",");
128
- searchParams.highlight_affix_num_tokens = 5;
129
- }
130
- const result = await this.client
131
- .collections(collectionName)
132
- .documents()
133
- .search(searchParams);
134
- const processingTimeMs = performance.now() - startMs;
135
- const hits = (result?.hits || []).map((hit) => ({
136
- doc: hit.document,
137
- score: hit.text_match_info?.score ?? 1,
138
- highlights: hit.highlight ?? undefined,
139
- }));
140
- const totalHits = result?.found ?? 0;
141
- const totalPages = Math.ceil(totalHits / hitsPerPage);
142
- const facetDistribution = {};
143
- const resultAny = result;
144
- if (resultAny?.facet_counts) {
145
- for (const facet of resultAny.facet_counts) {
146
- facetDistribution[facet.field_name] = {};
147
- for (const count of facet.counts) {
148
- if (count.value !== undefined && count.count !== undefined) {
149
- const field = facetDistribution[facet.field_name];
150
- if (field) {
151
- field[count.value] = count.count;
152
- }
153
- }
154
- }
155
- }
156
- }
157
- logger.debug("[search:typesense] Search completed", {
158
- collection: collectionName,
159
- query: query.query,
160
- hits: hits.length,
161
- totalHits,
162
- processingTimeMs,
163
- });
164
- return {
165
- hits,
166
- totalHits,
167
- processingTimeMs,
168
- facetDistribution: (Object.keys(facetDistribution).length > 0 ? facetDistribution : undefined) ?? {},
169
- page,
170
- hitsPerPage,
171
- totalPages,
172
- };
173
- }
174
- catch (error) {
175
- logger.error("[search:typesense] Search failed", {
176
- index,
177
- query: query.query,
178
- error: error instanceof Error ? error.message : String(error),
179
- });
180
- throw error;
181
- }
182
- }
183
- async deleteDocument(index, docId, tenantId) {
184
- try {
185
- const collectionName = this._getCollectionName(index, tenantId);
186
- await this.client.collections(collectionName).documents(docId).delete();
187
- logger.debug("[search:typesense] Deleted document", {
188
- collection: collectionName,
189
- docId,
190
- });
191
- }
192
- catch (error) {
193
- logger.error("[search:typesense] Failed to delete document", {
194
- index,
195
- docId,
196
- error: error instanceof Error ? error.message : String(error),
197
- });
198
- throw error;
199
- }
200
- }
201
- async deleteByFilter(index, filters) {
202
- try {
203
- // Build filter string
204
- const filterParts = [];
205
- for (const [key, value] of Object.entries(filters)) {
206
- if (typeof value === "string") {
207
- filterParts.push(`${key}:=${value}`);
208
- }
209
- else if (typeof value === "number") {
210
- filterParts.push(`${key}:=${value}`);
211
- }
212
- else if (typeof value === "boolean") {
213
- filterParts.push(`${key}:=${value ? 1 : 0}`);
214
- }
215
- }
216
- const filterBy = filterParts.join(" && ");
217
- // Search for matching documents
218
- const result = await this.client
219
- .collections(index)
220
- .documents()
221
- .search({ q: "*", filter_by: filterBy, limit: 10000 });
222
- if (result?.hits && result.hits.length > 0) {
223
- for (const hit of result.hits) {
224
- await this.client.collections(index).documents(hit.document.id).delete();
225
- }
226
- logger.debug("[search:typesense] Deleted documents by filter", {
227
- collection: index,
228
- filters,
229
- deletedCount: result.hits.length,
230
- });
231
- }
232
- }
233
- catch (error) {
234
- logger.error("[search:typesense] Failed to delete by filter", {
235
- index,
236
- filters,
237
- error: error instanceof Error ? error.message : String(error),
238
- });
239
- throw error;
240
- }
241
- }
242
- async createIndex(index, settings) {
243
- try {
244
- // Typesense requires explicit schema definition
245
- const schema = this._buildSchema(index, settings);
246
- await this.client.collections().create(schema);
247
- logger.info("[search:typesense] Collection created", { collection: index });
248
- }
249
- catch (error) {
250
- if (error instanceof Error && error.message.includes("Already exists")) {
251
- logger.debug("[search:typesense] Collection already exists", { collection: index });
252
- return;
253
- }
254
- logger.error("[search:typesense] Failed to create collection", {
255
- index,
256
- error: error instanceof Error ? error.message : String(error),
257
- });
258
- throw error;
259
- }
260
- }
261
- async updateSettings(index, _settings) {
262
- try {
263
- // Typesense doesn't support updating schema after creation
264
- // Log a warning and skip
265
- logger.warn("[search:typesense] Schema updates not supported after creation", {
266
- index,
267
- });
268
- }
269
- catch (error) {
270
- logger.error("[search:typesense] Failed to update settings", {
271
- index,
272
- error: error instanceof Error ? error.message : String(error),
273
- });
274
- throw error;
275
- }
276
- }
277
- async close() {
278
- logger.info("[search:typesense] Closing connection");
279
- // Typesense doesn't require explicit connection cleanup
280
- }
281
- // ─────────────────────────────────────────────────────────────────────────
282
- _getCollectionName(baseIndex, tenantId) {
283
- if (tenantId) {
284
- return `${baseIndex}__${tenantId}`;
285
- }
286
- return baseIndex;
287
- }
288
- _prepareDocument(doc) {
289
- return { ...doc };
290
- }
291
- _buildSchema(collectionName, settings) {
292
- const fields = [
293
- { name: "id", type: "string" },
294
- { name: "tenantId", type: "string", optional: true },
295
- ];
296
- // Add searchable fields
297
- if (settings.searchableAttributes) {
298
- for (const attr of settings.searchableAttributes) {
299
- if (!["id", "tenantId"].includes(attr)) {
300
- fields.push({ name: attr, type: "string" });
301
- }
302
- }
303
- }
304
- // Add facetable fields
305
- if (settings.facetableAttributes) {
306
- for (const attr of settings.facetableAttributes) {
307
- if (!fields.some((f) => f.name === attr)) {
308
- fields.push({ name: attr, type: "string", facet: true });
309
- }
310
- }
311
- }
312
- return {
313
- name: collectionName,
314
- fields,
315
- default_sorting_field: "id",
316
- };
317
- }
318
- _getQueryByFields() {
319
- // Default searchable fields
320
- return "id,tenantId";
321
- }
322
- _parseHostFromUrl(url) {
323
- try {
324
- const urlObj = new URL(url);
325
- return urlObj.hostname;
326
- }
327
- catch {
328
- return "localhost";
329
- }
330
- }
331
- _parsePortFromUrl(url) {
332
- try {
333
- const urlObj = new URL(url);
334
- if (urlObj.port) {
335
- return parseInt(urlObj.port, 10);
336
- }
337
- return urlObj.protocol === "https:" ? 443 : 80;
338
- }
339
- catch {
340
- return 8108;
341
- }
342
- }
343
- _parseProtocolFromUrl(url) {
344
- try {
345
- const urlObj = new URL(url);
346
- return urlObj.protocol.replace(":", "");
347
- }
348
- catch {
349
- return "http";
350
- }
351
- }
352
- }