@nebutra/search 0.1.0 → 0.1.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.
- package/dist/factory.d.ts +39 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +138 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/providers/algolia.d.ts +20 -0
- package/dist/providers/algolia.d.ts.map +1 -0
- package/dist/providers/algolia.js +297 -0
- package/dist/providers/meilisearch.d.ts +17 -0
- package/dist/providers/meilisearch.d.ts.map +1 -0
- package/dist/providers/meilisearch.js +279 -0
- package/dist/providers/pgvector.d.ts +24 -0
- package/dist/providers/pgvector.d.ts.map +1 -0
- package/dist/providers/pgvector.js +249 -0
- package/dist/providers/typesense.d.ts +22 -0
- package/dist/providers/typesense.d.ts.map +1 -0
- package/dist/providers/typesense.js +352 -0
- package/dist/types.d.ts +163 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/package.json +3 -3
|
@@ -0,0 +1,352 @@
|
|
|
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Supported search backend providers.
|
|
4
|
+
*
|
|
5
|
+
* - `meilisearch` — Self-hosted, developer-friendly, typo-tolerant
|
|
6
|
+
* - `typesense` — Self-hosted, optimised for geo search and faceting
|
|
7
|
+
* - `algolia` — Managed SaaS, zero-ops, global CDN
|
|
8
|
+
* - `pgvector` — Postgres + pgvector extension; BM25 keyword + vector
|
|
9
|
+
* cosine search. Choose this when AI/RAG over your own
|
|
10
|
+
* Postgres is the primary use case (no external search
|
|
11
|
+
* infra to operate).
|
|
12
|
+
*/
|
|
13
|
+
export type SearchProviderType = "meilisearch" | "typesense" | "algolia" | "pgvector";
|
|
14
|
+
/**
|
|
15
|
+
* A document that can be indexed by the search provider.
|
|
16
|
+
* All documents must have an id; tenantId is optional for multi-tenancy.
|
|
17
|
+
* Additional fields are arbitrary and searchable.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SearchDocumentSchema: z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$loose>;
|
|
23
|
+
export type SearchDocument = z.infer<typeof SearchDocumentSchema>;
|
|
24
|
+
export declare const SearchQuerySchema: z.ZodObject<{
|
|
25
|
+
query: z.ZodString;
|
|
26
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
27
|
+
filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
28
|
+
facets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
|
+
sort: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
30
|
+
page: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
hitsPerPage: z.ZodOptional<z.ZodNumber>;
|
|
32
|
+
highlightFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
33
|
+
typoTolerance: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
+
minScore: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export type SearchQuery = z.infer<typeof SearchQuerySchema>;
|
|
37
|
+
export interface SearchHit<T extends SearchDocument = SearchDocument> {
|
|
38
|
+
/** The matched document */
|
|
39
|
+
doc: T;
|
|
40
|
+
/** Score (0-1) — higher is more relevant */
|
|
41
|
+
score: number;
|
|
42
|
+
/** Field highlights (HTML-safe snippets with <mark> tags) */
|
|
43
|
+
highlights?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
export interface SearchResult<T extends SearchDocument = SearchDocument> {
|
|
46
|
+
/** Matching documents with relevance scores */
|
|
47
|
+
hits: SearchHit<T>[];
|
|
48
|
+
/** Total number of matches (before pagination) */
|
|
49
|
+
totalHits: number;
|
|
50
|
+
/** Time taken by the provider (in milliseconds) */
|
|
51
|
+
processingTimeMs: number;
|
|
52
|
+
/** Facet distribution for filter UI (if requested) */
|
|
53
|
+
facetDistribution?: Record<string, Record<string, number>>;
|
|
54
|
+
/** Current page number (1-indexed) */
|
|
55
|
+
page: number;
|
|
56
|
+
/** Hits per page */
|
|
57
|
+
hitsPerPage: number;
|
|
58
|
+
/** Total pages (calculated from totalHits / hitsPerPage) */
|
|
59
|
+
totalPages: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every search backend must implement this interface.
|
|
63
|
+
* The factory function (`createSearch`) returns a `SearchProvider`.
|
|
64
|
+
*/
|
|
65
|
+
export interface SearchProvider {
|
|
66
|
+
readonly name: SearchProviderType;
|
|
67
|
+
/**
|
|
68
|
+
* Index a single document.
|
|
69
|
+
* If the document already exists, it is updated (upsert semantics).
|
|
70
|
+
*/
|
|
71
|
+
indexDocument<T extends SearchDocument>(index: string, doc: T): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Index multiple documents in a batch operation.
|
|
74
|
+
* More efficient than sequential indexDocument() calls.
|
|
75
|
+
*/
|
|
76
|
+
indexDocuments<T extends SearchDocument>(index: string, docs: T[]): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Search for documents matching a query.
|
|
79
|
+
*/
|
|
80
|
+
search<T extends SearchDocument = SearchDocument>(index: string, query: SearchQuery): Promise<SearchResult<T>>;
|
|
81
|
+
/**
|
|
82
|
+
* Delete a single document from an index.
|
|
83
|
+
*/
|
|
84
|
+
deleteDocument(index: string, docId: string, tenantId?: string): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Delete multiple documents matching a filter.
|
|
87
|
+
* Typically used for tenant cleanup: deleteByFilter(index, { tenantId: "org_123" })
|
|
88
|
+
*/
|
|
89
|
+
deleteByFilter(index: string, filters: Record<string, string | number | boolean>): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Create or update an index with optional settings.
|
|
92
|
+
* Called during app initialization to set up searchable fields.
|
|
93
|
+
*/
|
|
94
|
+
createIndex(index: string, settings: IndexSettings): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Update index settings (e.g., searchable attributes, ranking rules).
|
|
97
|
+
*/
|
|
98
|
+
updateSettings(index: string, settings: IndexSettings): Promise<void>;
|
|
99
|
+
/**
|
|
100
|
+
* Graceful shutdown — close connections, flush in-flight operations.
|
|
101
|
+
*/
|
|
102
|
+
close(): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
export interface IndexSettings {
|
|
105
|
+
/** Attributes that should be searchable (default: all) */
|
|
106
|
+
searchableAttributes?: string[];
|
|
107
|
+
/** Attributes used for filtering */
|
|
108
|
+
filterableAttributes?: string[];
|
|
109
|
+
/** Attributes to use for faceting */
|
|
110
|
+
facetableAttributes?: string[];
|
|
111
|
+
/** Attributes used for sorting */
|
|
112
|
+
sortableAttributes?: string[];
|
|
113
|
+
/** Primary key / unique field (default: "id") */
|
|
114
|
+
primaryKey?: string;
|
|
115
|
+
/** Custom ranking rules (provider-specific) */
|
|
116
|
+
rankingRules?: string[];
|
|
117
|
+
/** Synonyms for the index (e.g., { "dashboard": ["overview", "analytics"] }) */
|
|
118
|
+
synonyms?: Record<string, string[]>;
|
|
119
|
+
}
|
|
120
|
+
export interface MeilisearchConfig {
|
|
121
|
+
provider: "meilisearch";
|
|
122
|
+
/** Meilisearch server URL (defaults to `process.env.MEILISEARCH_URL`) */
|
|
123
|
+
url?: string;
|
|
124
|
+
/** API key with at least search + indexing permissions (defaults to `process.env.MEILISEARCH_API_KEY`) */
|
|
125
|
+
apiKey?: string;
|
|
126
|
+
/** Default timeout for requests (in milliseconds, default: 30000) */
|
|
127
|
+
timeout?: number;
|
|
128
|
+
}
|
|
129
|
+
export interface TypesenseConfig {
|
|
130
|
+
provider: "typesense";
|
|
131
|
+
/** Typesense server URL (defaults to `process.env.TYPESENSE_URL`) */
|
|
132
|
+
url?: string;
|
|
133
|
+
/** API key for authentication (defaults to `process.env.TYPESENSE_API_KEY`) */
|
|
134
|
+
apiKey?: string;
|
|
135
|
+
/** Default timeout for requests (in milliseconds, default: 30000) */
|
|
136
|
+
timeout?: number;
|
|
137
|
+
}
|
|
138
|
+
export interface AlgoliaConfig {
|
|
139
|
+
provider: "algolia";
|
|
140
|
+
/** Algolia app ID (defaults to `process.env.ALGOLIA_APP_ID`) */
|
|
141
|
+
appId?: string;
|
|
142
|
+
/** Algolia search API key (defaults to `process.env.ALGOLIA_SEARCH_KEY`) */
|
|
143
|
+
searchKey?: string;
|
|
144
|
+
/** Algolia admin API key for indexing (defaults to `process.env.ALGOLIA_ADMIN_KEY`) */
|
|
145
|
+
adminKey?: string;
|
|
146
|
+
}
|
|
147
|
+
export interface PgvectorConfig {
|
|
148
|
+
provider: "pgvector";
|
|
149
|
+
/** Postgres connection string (defaults to `process.env.DATABASE_URL`) */
|
|
150
|
+
connectionString?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Embedding vector dimension. All documents indexed under this provider
|
|
153
|
+
* must use the same dimension. Defaults to 1536 (OpenAI text-embedding-3-small).
|
|
154
|
+
*/
|
|
155
|
+
embeddingDim?: number;
|
|
156
|
+
/**
|
|
157
|
+
* Optional table-name prefix. Each index becomes a table named
|
|
158
|
+
* `<prefix>_<index>`. Defaults to `nebutra_search`.
|
|
159
|
+
*/
|
|
160
|
+
tablePrefix?: string;
|
|
161
|
+
}
|
|
162
|
+
export type SearchConfig = MeilisearchConfig | TypesenseConfig | AlgoliaConfig | PgvectorConfig;
|
|
163
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// ── Search Document ────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* A document that can be indexed by the search provider.
|
|
5
|
+
* All documents must have an id; tenantId is optional for multi-tenancy.
|
|
6
|
+
* Additional fields are arbitrary and searchable.
|
|
7
|
+
*/
|
|
8
|
+
export const SearchDocumentSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
/** Globally unique document ID within the index */
|
|
11
|
+
id: z.string(),
|
|
12
|
+
/** Optional tenant/workspace ID for multi-tenant filtering */
|
|
13
|
+
tenantId: z.string().optional(),
|
|
14
|
+
/** Arbitrary searchable fields — flattened at index time */
|
|
15
|
+
})
|
|
16
|
+
.passthrough();
|
|
17
|
+
// ── Search Query ──────────────────────────────────────────────────────────
|
|
18
|
+
export const SearchQuerySchema = z.object({
|
|
19
|
+
/** Full-text search query string (e.g., "analytics dashboard") */
|
|
20
|
+
query: z.string(),
|
|
21
|
+
/** Tenant ID for filtering results to a specific tenant */
|
|
22
|
+
tenantId: z.string().optional(),
|
|
23
|
+
/** Filters to apply (provider-agnostic key-value pairs) */
|
|
24
|
+
filters: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
25
|
+
/** Facet fields to include in results (for filtering UI) */
|
|
26
|
+
facets: z.array(z.string()).optional(),
|
|
27
|
+
/** Sort order (e.g., ["createdAt:desc", "relevance:asc"]) */
|
|
28
|
+
sort: z.array(z.string()).optional(),
|
|
29
|
+
/** Page number (1-indexed, default: 1) */
|
|
30
|
+
page: z.number().int().min(1).optional(),
|
|
31
|
+
/** Hits per page (default: 20, max: 100) */
|
|
32
|
+
hitsPerPage: z.number().int().min(1).max(100).optional(),
|
|
33
|
+
/** Fields to return highlights for (where supported) */
|
|
34
|
+
highlightFields: z.array(z.string()).optional(),
|
|
35
|
+
/** Enable typo tolerance (default: true) */
|
|
36
|
+
typoTolerance: z.boolean().optional(),
|
|
37
|
+
/** Minimum score threshold (0-1, provider-dependent interpretation) */
|
|
38
|
+
minScore: z.number().min(0).max(1).optional(),
|
|
39
|
+
});
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/search",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"private": false,
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"nebutra": {
|
|
8
8
|
"status": "foundation",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"algoliasearch": "^5.20.0",
|
|
53
53
|
"pg": "^8.13.1",
|
|
54
54
|
"zod": "^4.3.6",
|
|
55
|
-
"@nebutra/logger": "0.1.
|
|
55
|
+
"@nebutra/logger": "0.1.1"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/pg": "^8.11.10",
|