@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.
@@ -0,0 +1,279 @@
1
+ import { logger } from "@nebutra/logger";
2
+ import { MeiliSearch } from "meilisearch";
3
+ // =============================================================================
4
+ // Meilisearch Provider — Developer-friendly, typo-tolerant search
5
+ // =============================================================================
6
+ export class MeilisearchProvider {
7
+ name = "meilisearch";
8
+ client;
9
+ constructor(config) {
10
+ const url = config?.url ?? process.env.MEILISEARCH_URL ?? "http://localhost:7700";
11
+ const _apiKey = config?.apiKey ?? process.env.MEILISEARCH_API_KEY;
12
+ const timeout = config?.timeout ?? 30000;
13
+ logger.info("[search:meilisearch] Initializing", { url });
14
+ const clientConfig = {
15
+ host: url,
16
+ timeout,
17
+ };
18
+ if (_apiKey) {
19
+ clientConfig.apiKey = _apiKey;
20
+ }
21
+ this.client = new MeiliSearch(clientConfig);
22
+ }
23
+ async indexDocument(index, doc) {
24
+ try {
25
+ const indexWithTenant = this._getTenantIndex(index, doc.tenantId);
26
+ await this.client.index(indexWithTenant).addDocuments([doc]);
27
+ logger.debug("[search:meilisearch] Indexed document", {
28
+ index: indexWithTenant,
29
+ docId: doc.id,
30
+ });
31
+ }
32
+ catch (error) {
33
+ logger.error("[search:meilisearch] Failed to index document", {
34
+ index,
35
+ docId: doc.id,
36
+ error: error instanceof Error ? error.message : String(error),
37
+ });
38
+ throw error;
39
+ }
40
+ }
41
+ async indexDocuments(index, docs) {
42
+ if (docs.length === 0)
43
+ return;
44
+ try {
45
+ // Group documents by tenant for proper filtering
46
+ const byTenant = new Map();
47
+ for (const doc of docs) {
48
+ const tenant = doc.tenantId;
49
+ if (!byTenant.has(tenant)) {
50
+ byTenant.set(tenant, []);
51
+ }
52
+ byTenant.get(tenant)?.push(doc);
53
+ }
54
+ // Index each tenant's documents to their own index
55
+ for (const [tenantId, tenantDocs] of byTenant.entries()) {
56
+ const indexWithTenant = this._getTenantIndex(index, tenantId);
57
+ await this.client
58
+ .index(indexWithTenant)
59
+ .addDocuments(tenantDocs);
60
+ logger.debug("[search:meilisearch] Indexed batch", {
61
+ index: indexWithTenant,
62
+ count: tenantDocs.length,
63
+ });
64
+ }
65
+ }
66
+ catch (error) {
67
+ logger.error("[search:meilisearch] Failed to index batch", {
68
+ index,
69
+ count: docs.length,
70
+ error: error instanceof Error ? error.message : String(error),
71
+ });
72
+ throw error;
73
+ }
74
+ }
75
+ async search(index, query) {
76
+ try {
77
+ const indexWithTenant = this._getTenantIndex(index, query.tenantId);
78
+ const page = query.page ?? 1;
79
+ const hitsPerPage = Math.min(query.hitsPerPage ?? 20, 100);
80
+ const offset = (page - 1) * hitsPerPage;
81
+ // Build filter string for Meilisearch
82
+ let filter;
83
+ if (query.filters || query.tenantId) {
84
+ const filterParts = [];
85
+ // Add tenant filter if present
86
+ if (query.tenantId) {
87
+ filterParts.push(`tenantId = "${query.tenantId}"`);
88
+ }
89
+ // Add additional filters
90
+ if (query.filters) {
91
+ for (const [key, value] of Object.entries(query.filters)) {
92
+ if (typeof value === "string") {
93
+ filterParts.push(`${key} = "${value}"`);
94
+ }
95
+ else if (typeof value === "number") {
96
+ filterParts.push(`${key} = ${value}`);
97
+ }
98
+ else if (typeof value === "boolean") {
99
+ filterParts.push(`${key} = ${value}`);
100
+ }
101
+ }
102
+ }
103
+ if (filterParts.length > 0) {
104
+ filter = filterParts.join(" AND ");
105
+ }
106
+ }
107
+ const startMs = performance.now();
108
+ const searchParams = {
109
+ offset,
110
+ limit: hitsPerPage,
111
+ highlightPreTag: "<mark>",
112
+ highlightPostTag: "</mark>",
113
+ };
114
+ if (filter !== undefined)
115
+ searchParams.filter = filter;
116
+ if (query.facets !== undefined)
117
+ searchParams.facets = query.facets;
118
+ if (query.sort !== undefined)
119
+ searchParams.sort = query.sort;
120
+ if (query.highlightFields !== undefined)
121
+ searchParams.attributesToHighlight = query.highlightFields;
122
+ const result = await this.client.index(indexWithTenant).search(query.query, searchParams);
123
+ const processingTimeMs = performance.now() - startMs;
124
+ const hits = (result.hits || []).map((hit) => ({
125
+ doc: hit,
126
+ score: hit._rankingScore ?? 1,
127
+ highlights: hit._formatted ?? undefined,
128
+ }));
129
+ const totalHits = result.estimatedTotalHits ?? 0;
130
+ const totalPages = Math.ceil(totalHits / hitsPerPage);
131
+ logger.debug("[search:meilisearch] Search completed", {
132
+ index: indexWithTenant,
133
+ query: query.query,
134
+ hits: hits.length,
135
+ totalHits,
136
+ processingTimeMs,
137
+ });
138
+ return {
139
+ hits,
140
+ totalHits,
141
+ processingTimeMs,
142
+ facetDistribution: result.facetDistribution ?? {},
143
+ page,
144
+ hitsPerPage,
145
+ totalPages,
146
+ };
147
+ }
148
+ catch (error) {
149
+ logger.error("[search:meilisearch] Search failed", {
150
+ index,
151
+ query: query.query,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ });
154
+ throw error;
155
+ }
156
+ }
157
+ async deleteDocument(index, docId, tenantId) {
158
+ try {
159
+ const indexWithTenant = this._getTenantIndex(index, tenantId);
160
+ await this.client.index(indexWithTenant).deleteDocument(docId);
161
+ logger.debug("[search:meilisearch] Deleted document", {
162
+ index: indexWithTenant,
163
+ docId,
164
+ });
165
+ }
166
+ catch (error) {
167
+ logger.error("[search:meilisearch] Failed to delete document", {
168
+ index,
169
+ docId,
170
+ error: error instanceof Error ? error.message : String(error),
171
+ });
172
+ throw error;
173
+ }
174
+ }
175
+ async deleteByFilter(index, filters) {
176
+ try {
177
+ // Build filter string
178
+ const filterParts = [];
179
+ for (const [key, value] of Object.entries(filters)) {
180
+ if (typeof value === "string") {
181
+ filterParts.push(`${key} = "${value}"`);
182
+ }
183
+ else if (typeof value === "number") {
184
+ filterParts.push(`${key} = ${value}`);
185
+ }
186
+ else if (typeof value === "boolean") {
187
+ filterParts.push(`${key} = ${value}`);
188
+ }
189
+ }
190
+ const filter = filterParts.join(" AND ");
191
+ // Meilisearch doesn't have a direct deleteByFilter, so we search then delete
192
+ const result = await this.client.index(index).search("", {
193
+ filter,
194
+ limit: 10000,
195
+ });
196
+ if (result.hits.length > 0) {
197
+ const docIds = result.hits.map((hit) => hit.id);
198
+ await this.client.index(index).deleteDocuments(docIds);
199
+ logger.debug("[search:meilisearch] Deleted documents by filter", {
200
+ index,
201
+ filters,
202
+ deletedCount: docIds.length,
203
+ });
204
+ }
205
+ }
206
+ catch (error) {
207
+ logger.error("[search:meilisearch] Failed to delete by filter", {
208
+ index,
209
+ filters,
210
+ error: error instanceof Error ? error.message : String(error),
211
+ });
212
+ throw error;
213
+ }
214
+ }
215
+ async createIndex(index, settings) {
216
+ try {
217
+ // Meilisearch creates indices on-demand, so we update settings instead
218
+ await this._applySettings(index, settings);
219
+ logger.info("[search:meilisearch] Index created/configured", { index });
220
+ }
221
+ catch (error) {
222
+ logger.error("[search:meilisearch] Failed to create index", {
223
+ index,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ });
226
+ throw error;
227
+ }
228
+ }
229
+ async updateSettings(index, settings) {
230
+ try {
231
+ await this._applySettings(index, settings);
232
+ logger.info("[search:meilisearch] Settings updated", { index });
233
+ }
234
+ catch (error) {
235
+ logger.error("[search:meilisearch] Failed to update settings", {
236
+ index,
237
+ error: error instanceof Error ? error.message : String(error),
238
+ });
239
+ throw error;
240
+ }
241
+ }
242
+ async close() {
243
+ logger.info("[search:meilisearch] Closing connection");
244
+ // Meilisearch doesn't require explicit connection cleanup
245
+ }
246
+ // ─────────────────────────────────────────────────────────────────────────
247
+ _getTenantIndex(baseIndex, tenantId) {
248
+ if (tenantId) {
249
+ return `${baseIndex}__${tenantId}`;
250
+ }
251
+ return baseIndex;
252
+ }
253
+ async _applySettings(index, settings) {
254
+ const indexObj = this.client.index(index);
255
+ if (settings.searchableAttributes) {
256
+ await indexObj.updateSearchableAttributes(settings.searchableAttributes);
257
+ }
258
+ if (settings.filterableAttributes) {
259
+ await indexObj.updateFilterableAttributes(settings.filterableAttributes);
260
+ }
261
+ if (settings.facetableAttributes) {
262
+ // Note: updateFacetedSearch doesn't exist on Index, skip this step
263
+ // await indexObj.updateFacetedSearch({ facets: settings.facetableAttributes });
264
+ }
265
+ if (settings.sortableAttributes) {
266
+ await indexObj.updateSortableAttributes(settings.sortableAttributes);
267
+ }
268
+ if (settings.rankingRules) {
269
+ await indexObj.updateRankingRules(settings.rankingRules);
270
+ }
271
+ if (settings.synonyms) {
272
+ await indexObj.updateSynonyms(settings.synonyms);
273
+ }
274
+ if (settings.primaryKey) {
275
+ // Primary key cannot be changed after index creation in Meilisearch
276
+ // This is a no-op for existing indices
277
+ }
278
+ }
279
+ }
@@ -0,0 +1,24 @@
1
+ import type { IndexSettings, PgvectorConfig, SearchDocument, SearchProvider, SearchQuery, SearchResult } from "../types";
2
+ export declare class PgvectorProvider implements SearchProvider {
3
+ readonly name: "pgvector";
4
+ private pool;
5
+ private embeddingDim;
6
+ private tablePrefix;
7
+ private bootstrappedTables;
8
+ private bootstrappedExtension;
9
+ constructor(config?: PgvectorConfig);
10
+ private tableName;
11
+ private ensureExtension;
12
+ private ensureTable;
13
+ private extractText;
14
+ private embeddingLiteral;
15
+ indexDocument<T extends SearchDocument>(index: string, doc: T): Promise<void>;
16
+ indexDocuments<T extends SearchDocument>(index: string, docs: T[]): Promise<void>;
17
+ search<T extends SearchDocument = SearchDocument>(index: string, query: SearchQuery): Promise<SearchResult<T>>;
18
+ deleteDocument(index: string, docId: string, tenantId?: string): Promise<void>;
19
+ deleteByFilter(index: string, filters: Record<string, string | number | boolean>): Promise<void>;
20
+ createIndex(index: string, _settings: IndexSettings): Promise<void>;
21
+ updateSettings(_index: string, _settings: IndexSettings): Promise<void>;
22
+ close(): Promise<void>;
23
+ }
24
+ //# sourceMappingURL=pgvector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pgvector.d.ts","sourceRoot":"","sources":["../../src/providers/pgvector.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,cAAc,EAEd,cAAc,EACd,WAAW,EACX,YAAY,EACb,MAAM,UAAU,CAAC;AA+BlB,qBAAa,gBAAiB,YAAW,cAAc;IACrD,QAAQ,CAAC,IAAI,EAAG,UAAU,CAAU;IAEpC,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,qBAAqB,CAAS;gBAE1B,MAAM,CAAC,EAAE,cAAc;IAoBnC,OAAO,CAAC,SAAS;YAKH,eAAe;YAcf,WAAW;IAgCzB,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,gBAAgB;IAUlB,aAAa,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAqB7E,cAAc,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAWjF,MAAM,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,EACpD,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IA8ErB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa9E,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IAwBV,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnE,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAKvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAM7B"}
@@ -0,0 +1,249 @@
1
+ import { logger } from "@nebutra/logger";
2
+ import { Pool } from "pg";
3
+ // =============================================================================
4
+ // pgvector Provider — Postgres + pgvector hybrid search
5
+ // =============================================================================
6
+ // Each index is one Postgres table:
7
+ // <prefix>_<index> (id text PK, tenant_id text, doc jsonb,
8
+ // text tsvector, embedding vector(<dim>))
9
+ //
10
+ // Documents may carry an optional `_embedding: number[]` field — present →
11
+ // query can do cosine-distance vector search; absent → BM25 keyword search
12
+ // via tsvector + plainto_tsquery.
13
+ //
14
+ // Multi-tenancy: every row carries `tenant_id`; queries that pass
15
+ // `query.tenantId` add a WHERE clause. Without tenantId the query scans
16
+ // the whole table — appropriate for system-wide search but consumers must
17
+ // be aware.
18
+ // =============================================================================
19
+ const SAFE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
20
+ function assertSafeIdentifier(name, kind) {
21
+ if (!SAFE_NAME_RE.test(name)) {
22
+ throw new Error(`[search:pgvector] Unsafe ${kind} name: ${name}`);
23
+ }
24
+ }
25
+ export class PgvectorProvider {
26
+ name = "pgvector";
27
+ pool;
28
+ embeddingDim;
29
+ tablePrefix;
30
+ bootstrappedTables = new Set();
31
+ bootstrappedExtension = false;
32
+ constructor(config) {
33
+ const connectionString = config?.connectionString ?? process.env.DATABASE_URL;
34
+ if (!connectionString) {
35
+ throw new Error("[search:pgvector] DATABASE_URL not set and no `connectionString` passed in config.");
36
+ }
37
+ this.pool = new Pool({ connectionString });
38
+ this.embeddingDim = config?.embeddingDim ?? 1536;
39
+ this.tablePrefix = config?.tablePrefix ?? "nebutra_search";
40
+ assertSafeIdentifier(this.tablePrefix, "tablePrefix");
41
+ logger.info("[search:pgvector] Provider initialised", {
42
+ embeddingDim: this.embeddingDim,
43
+ tablePrefix: this.tablePrefix,
44
+ });
45
+ }
46
+ // ── Table & extension bootstrap ─────────────────────────────────────────
47
+ tableName(index) {
48
+ assertSafeIdentifier(index, "index");
49
+ return `${this.tablePrefix}_${index}`;
50
+ }
51
+ async ensureExtension(client) {
52
+ if (this.bootstrappedExtension)
53
+ return;
54
+ try {
55
+ await client.query("CREATE EXTENSION IF NOT EXISTS vector");
56
+ this.bootstrappedExtension = true;
57
+ }
58
+ catch (error) {
59
+ logger.error("[search:pgvector] CREATE EXTENSION vector failed — install the pgvector extension on your Postgres or grant CREATE EXTENSION rights", { error: error instanceof Error ? error.message : String(error) });
60
+ throw error;
61
+ }
62
+ }
63
+ async ensureTable(index) {
64
+ if (this.bootstrappedTables.has(index))
65
+ return;
66
+ const table = this.tableName(index);
67
+ const client = await this.pool.connect();
68
+ try {
69
+ await this.ensureExtension(client);
70
+ await client.query(`
71
+ CREATE TABLE IF NOT EXISTS ${table} (
72
+ id TEXT PRIMARY KEY,
73
+ tenant_id TEXT,
74
+ doc JSONB NOT NULL,
75
+ text TSVECTOR,
76
+ embedding VECTOR(${this.embeddingDim}),
77
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
78
+ )
79
+ `);
80
+ await client.query(`CREATE INDEX IF NOT EXISTS ${table}_tenant_idx ON ${table} (tenant_id)`);
81
+ await client.query(`CREATE INDEX IF NOT EXISTS ${table}_text_idx ON ${table} USING GIN (text)`);
82
+ // ivfflat needs ANALYZE for good performance; lists=100 is a reasonable default.
83
+ await client.query(`CREATE INDEX IF NOT EXISTS ${table}_embed_idx ON ${table} USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`);
84
+ this.bootstrappedTables.add(index);
85
+ }
86
+ finally {
87
+ client.release();
88
+ }
89
+ }
90
+ // ── Upsert ──────────────────────────────────────────────────────────────
91
+ extractText(doc) {
92
+ const parts = [];
93
+ for (const [k, v] of Object.entries(doc)) {
94
+ if (k === "_embedding" || k === "id" || k === "tenantId")
95
+ continue;
96
+ if (typeof v === "string")
97
+ parts.push(v);
98
+ else if (typeof v === "number" || typeof v === "boolean")
99
+ parts.push(String(v));
100
+ }
101
+ return parts.join(" ");
102
+ }
103
+ embeddingLiteral(embedding) {
104
+ if (!embedding || embedding.length === 0)
105
+ return null;
106
+ if (embedding.length !== this.embeddingDim) {
107
+ throw new Error(`[search:pgvector] Embedding length ${embedding.length} ≠ configured dim ${this.embeddingDim}`);
108
+ }
109
+ return `[${embedding.join(",")}]`;
110
+ }
111
+ async indexDocument(index, doc) {
112
+ await this.ensureTable(index);
113
+ const table = this.tableName(index);
114
+ const text = this.extractText(doc);
115
+ const embedding = doc._embedding;
116
+ await this.pool.query(`
117
+ INSERT INTO ${table} (id, tenant_id, doc, text, embedding, updated_at)
118
+ VALUES ($1, $2, $3::jsonb, to_tsvector('english', $4), $5::vector, NOW())
119
+ ON CONFLICT (id) DO UPDATE SET
120
+ tenant_id = EXCLUDED.tenant_id,
121
+ doc = EXCLUDED.doc,
122
+ text = EXCLUDED.text,
123
+ embedding = EXCLUDED.embedding,
124
+ updated_at = NOW()
125
+ `, [doc.id, doc.tenantId ?? null, JSON.stringify(doc), text, this.embeddingLiteral(embedding)]);
126
+ }
127
+ async indexDocuments(index, docs) {
128
+ if (docs.length === 0)
129
+ return;
130
+ // Simple sequential upsert; pg supports COPY for bulk but the API surface
131
+ // is heavier. Sequential keeps the implementation small and predictable.
132
+ for (const doc of docs) {
133
+ await this.indexDocument(index, doc);
134
+ }
135
+ }
136
+ // ── Search ──────────────────────────────────────────────────────────────
137
+ async search(index, query) {
138
+ await this.ensureTable(index);
139
+ const table = this.tableName(index);
140
+ const start = Date.now();
141
+ const page = query.page ?? 1;
142
+ const hitsPerPage = Math.min(query.hitsPerPage ?? 20, 100);
143
+ const offset = (page - 1) * hitsPerPage;
144
+ // Embedding-aware: callers can pass `filters._embedding` as a vector
145
+ // (encoded as comma-separated string) OR use plain text query for BM25.
146
+ const embeddingFilter = query.filters?._embedding;
147
+ const useVector = typeof embeddingFilter === "string" && embeddingFilter.startsWith("[");
148
+ const whereParts = [];
149
+ const params = [];
150
+ let p = 0;
151
+ if (query.tenantId) {
152
+ params.push(query.tenantId);
153
+ whereParts.push(`tenant_id = $${++p}`);
154
+ }
155
+ // Apply other simple equality filters from query.filters (skip _embedding).
156
+ for (const [k, v] of Object.entries(query.filters ?? {})) {
157
+ if (k === "_embedding")
158
+ continue;
159
+ assertSafeIdentifier(k, "filter key");
160
+ params.push(v);
161
+ whereParts.push(`(doc ->> '${k}') = $${++p}::text`);
162
+ }
163
+ let orderBy;
164
+ let selectScore;
165
+ if (useVector) {
166
+ params.push(embeddingFilter);
167
+ const embedParam = ++p;
168
+ selectScore = `1 - (embedding <=> $${embedParam}::vector) AS score`;
169
+ orderBy = `embedding <=> $${embedParam}::vector ASC`;
170
+ whereParts.push(`embedding IS NOT NULL`);
171
+ }
172
+ else {
173
+ params.push(query.query);
174
+ const queryParam = ++p;
175
+ selectScore = `ts_rank(text, plainto_tsquery('english', $${queryParam})) AS score`;
176
+ orderBy = `score DESC`;
177
+ whereParts.push(`text @@ plainto_tsquery('english', $${queryParam})`);
178
+ }
179
+ const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(" AND ")}` : "";
180
+ const countResult = await this.pool.query(`SELECT COUNT(*)::text AS count FROM ${table} ${whereSql}`, params);
181
+ const totalHits = Number.parseInt(countResult.rows[0]?.count ?? "0", 10);
182
+ params.push(hitsPerPage, offset);
183
+ const result = await this.pool.query(`SELECT doc, ${selectScore} FROM ${table} ${whereSql} ORDER BY ${orderBy} LIMIT $${++p} OFFSET $${++p}`, params);
184
+ const hits = result.rows.map((row) => ({
185
+ doc: row.doc,
186
+ score: Math.max(0, Math.min(1, Number(row.score) || 0)),
187
+ }));
188
+ return {
189
+ hits,
190
+ totalHits,
191
+ processingTimeMs: Date.now() - start,
192
+ page,
193
+ hitsPerPage,
194
+ totalPages: Math.max(1, Math.ceil(totalHits / hitsPerPage)),
195
+ };
196
+ }
197
+ // ── Delete ──────────────────────────────────────────────────────────────
198
+ async deleteDocument(index, docId, tenantId) {
199
+ await this.ensureTable(index);
200
+ const table = this.tableName(index);
201
+ if (tenantId) {
202
+ await this.pool.query(`DELETE FROM ${table} WHERE id = $1 AND tenant_id = $2`, [
203
+ docId,
204
+ tenantId,
205
+ ]);
206
+ }
207
+ else {
208
+ await this.pool.query(`DELETE FROM ${table} WHERE id = $1`, [docId]);
209
+ }
210
+ }
211
+ async deleteByFilter(index, filters) {
212
+ await this.ensureTable(index);
213
+ const table = this.tableName(index);
214
+ const whereParts = [];
215
+ const params = [];
216
+ let p = 0;
217
+ for (const [k, v] of Object.entries(filters)) {
218
+ if (k === "tenantId") {
219
+ params.push(v);
220
+ whereParts.push(`tenant_id = $${++p}`);
221
+ continue;
222
+ }
223
+ assertSafeIdentifier(k, "filter key");
224
+ params.push(v);
225
+ whereParts.push(`(doc ->> '${k}') = $${++p}::text`);
226
+ }
227
+ if (whereParts.length === 0) {
228
+ throw new Error("[search:pgvector] deleteByFilter requires at least one filter");
229
+ }
230
+ await this.pool.query(`DELETE FROM ${table} WHERE ${whereParts.join(" AND ")}`, params);
231
+ }
232
+ // ── Index Management ────────────────────────────────────────────────────
233
+ async createIndex(index, _settings) {
234
+ // pgvector doesn't have a separate "create index" concept beyond table
235
+ // bootstrap; settings like searchableAttributes are implicit (everything
236
+ // string-typed is concatenated into the tsvector by extractText()).
237
+ await this.ensureTable(index);
238
+ }
239
+ async updateSettings(_index, _settings) {
240
+ // No-op: pgvector schema is fixed at table creation. Customize by editing
241
+ // the table after bootstrap if needed (add columns, adjust GIN indexes).
242
+ }
243
+ async close() {
244
+ await this.pool.end();
245
+ this.bootstrappedTables.clear();
246
+ this.bootstrappedExtension = false;
247
+ logger.info("[search:pgvector] Provider closed");
248
+ }
249
+ }
@@ -0,0 +1,22 @@
1
+ import type { IndexSettings, SearchDocument, SearchProvider, SearchQuery, SearchResult, TypesenseConfig } from "../types";
2
+ export declare class TypesenseProvider implements SearchProvider {
3
+ readonly name = "typesense";
4
+ private client;
5
+ constructor(config?: TypesenseConfig);
6
+ indexDocument<T extends SearchDocument>(index: string, doc: T): Promise<void>;
7
+ indexDocuments<T extends SearchDocument>(index: string, docs: T[]): Promise<void>;
8
+ search<T extends SearchDocument = SearchDocument>(index: string, query: SearchQuery): Promise<SearchResult<T>>;
9
+ deleteDocument(index: string, docId: string, tenantId?: string): Promise<void>;
10
+ deleteByFilter(index: string, filters: Record<string, string | number | boolean>): Promise<void>;
11
+ createIndex(index: string, settings: IndexSettings): Promise<void>;
12
+ updateSettings(index: string, _settings: IndexSettings): Promise<void>;
13
+ close(): Promise<void>;
14
+ private _getCollectionName;
15
+ private _prepareDocument;
16
+ private _buildSchema;
17
+ private _getQueryByFields;
18
+ private _parseHostFromUrl;
19
+ private _parsePortFromUrl;
20
+ private _parseProtocolFromUrl;
21
+ }
22
+ //# sourceMappingURL=typesense.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typesense.d.ts","sourceRoot":"","sources":["../../src/providers/typesense.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,cAAc,EACd,WAAW,EACX,YAAY,EACZ,eAAe,EAChB,MAAM,UAAU,CAAC;AAMlB,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,IAAI,eAAe;IAC5B,OAAO,CAAC,MAAM,CAAU;gBAEZ,MAAM,CAAC,EAAE,eAAe;IAoB9B,aAAa,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB7E,cAAc,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjF,MAAM,CAAC,CAAC,SAAS,cAAc,GAAG,cAAc,EACpD,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAqHrB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB9E,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GACjD,OAAO,CAAC,IAAI,CAAC;IA0CV,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlE,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBtE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAO5B,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,gBAAgB;IAIxB,OAAO,CAAC,YAAY;IA+BpB,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,iBAAiB;IASzB,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,qBAAqB;CAQ9B"}