@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.
- package/README.md +21 -77
- package/dist/factory.d.ts +3 -11
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +4 -58
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -6
- package/dist/types.d.ts +4 -35
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -21
- package/dist/providers/algolia.d.ts +0 -20
- package/dist/providers/algolia.d.ts.map +0 -1
- package/dist/providers/algolia.js +0 -297
- package/dist/providers/meilisearch.d.ts +0 -17
- package/dist/providers/meilisearch.d.ts.map +0 -1
- package/dist/providers/meilisearch.js +0 -279
- package/dist/providers/typesense.d.ts +0 -22
- package/dist/providers/typesense.d.ts.map +0 -1
- package/dist/providers/typesense.js +0 -352
|
@@ -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
|
-
}
|