@medusajs/search-postgres 2.19.0-snapshot-20260811095123

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.
Files changed (39) hide show
  1. package/README.md +131 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +9 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/migrations/Migration20260807120000.d.ts +17 -0
  7. package/dist/migrations/Migration20260807120000.d.ts.map +1 -0
  8. package/dist/migrations/Migration20260807120000.js +56 -0
  9. package/dist/migrations/Migration20260807120000.js.map +1 -0
  10. package/dist/services/postgres-search.d.ts +200 -0
  11. package/dist/services/postgres-search.d.ts.map +1 -0
  12. package/dist/services/postgres-search.js +1054 -0
  13. package/dist/services/postgres-search.js.map +1 -0
  14. package/dist/tsconfig.tsbuildinfo +1 -0
  15. package/dist/utils/documents.d.ts +19 -0
  16. package/dist/utils/documents.d.ts.map +1 -0
  17. package/dist/utils/documents.js +97 -0
  18. package/dist/utils/documents.js.map +1 -0
  19. package/dist/utils/extensions.d.ts +50 -0
  20. package/dist/utils/extensions.d.ts.map +1 -0
  21. package/dist/utils/extensions.js +129 -0
  22. package/dist/utils/extensions.js.map +1 -0
  23. package/dist/utils/facets.d.ts +31 -0
  24. package/dist/utils/facets.d.ts.map +1 -0
  25. package/dist/utils/facets.js +198 -0
  26. package/dist/utils/facets.js.map +1 -0
  27. package/dist/utils/filters.d.ts +17 -0
  28. package/dist/utils/filters.d.ts.map +1 -0
  29. package/dist/utils/filters.js +237 -0
  30. package/dist/utils/filters.js.map +1 -0
  31. package/dist/utils/index.d.ts +6 -0
  32. package/dist/utils/index.d.ts.map +1 -0
  33. package/dist/utils/index.js +22 -0
  34. package/dist/utils/index.js.map +1 -0
  35. package/dist/utils/plan.d.ts +86 -0
  36. package/dist/utils/plan.d.ts.map +1 -0
  37. package/dist/utils/plan.js +238 -0
  38. package/dist/utils/plan.js.map +1 -0
  39. package/package.json +50 -0
@@ -0,0 +1,1054 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PostgresSearchService = void 0;
4
+ const utils_1 = require("@medusajs/framework/utils");
5
+ const utils_2 = require("../utils");
6
+ /** How many documents to write per multi-row upsert statement. */
7
+ const UPSERT_CHUNK_SIZE = 200;
8
+ /**
9
+ * PostgreSQL search provider with two engines:
10
+ *
11
+ * - `native` (default) — portable GIN + `ts_rank` + `pg_trgm`
12
+ * - `lakebase` — Neon Lakebase Search (`lakebase_bm25` + `lakebase_ann`)
13
+ *
14
+ * Medusa Cloud uses `engine: "lakebase"`. Local / self-hosted stick to native.
15
+ */
16
+ class PostgresSearchService extends utils_1.AbstractSearchProviderService {
17
+ constructor({ manager, logger }, options = {}) {
18
+ super();
19
+ this.extensionState_ = null;
20
+ if (!manager?.execute) {
21
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_ARGUMENT, "The postgres search provider requires a database manager. Ensure the Search Module is configured with a Postgres connection.");
22
+ }
23
+ this.manager_ = manager;
24
+ this.logger_ = logger;
25
+ this.language_ = this.normalizeLanguage(options.language ?? "english");
26
+ this.engine_ = options.engine ?? "native";
27
+ this.embedder_ = options.embedder;
28
+ this.vectorDistance_ = options.vector_distance ?? "cosine";
29
+ if (this.engine_ !== "native" && this.engine_ !== "lakebase") {
30
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Invalid postgres search engine "${options.engine}". Use "native" or "lakebase".`);
31
+ }
32
+ }
33
+ normalizeLanguage(language) {
34
+ if (!/^[a-z0-9_]+$/i.test(language)) {
35
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Invalid postgres search language "${language}". Use a built-in text search configuration name such as "english" or "simple".`);
36
+ }
37
+ return language;
38
+ }
39
+ get tsConfig_() {
40
+ return this.extensionState_?.text_search_config ?? this.language_;
41
+ }
42
+ get isLakebase_() {
43
+ return this.engine_ === "lakebase";
44
+ }
45
+ async ensureExtensions(manager = this.manager_) {
46
+ if (this.extensionState_) {
47
+ return this.extensionState_;
48
+ }
49
+ try {
50
+ this.extensionState_ = await (0, utils_2.ensureSearchExtensions)(manager, {
51
+ language: this.language_,
52
+ catalogTable: utils_2.CATALOG_TABLE,
53
+ engine: this.engine_,
54
+ });
55
+ }
56
+ catch (error) {
57
+ if (error instanceof utils_1.MedusaError) {
58
+ throw error;
59
+ }
60
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.DB_ERROR, `Failed to initialize the postgres search catalog. Underlying error: ${error.message}`);
61
+ }
62
+ if (!this.extensionState_.pg_trgm) {
63
+ this.logger_?.warn("search-postgres: pg_trgm is not available. Full-text search will work, but typo_tolerance queries will be rejected.");
64
+ }
65
+ return this.extensionState_;
66
+ }
67
+ assertTypoToleranceAvailable(state) {
68
+ if (!state.pg_trgm) {
69
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, "Typo tolerance requires the pg_trgm extension. Enable it with: CREATE EXTENSION IF NOT EXISTS pg_trgm;");
70
+ }
71
+ }
72
+ serializePlan(plan) {
73
+ return JSON.stringify({
74
+ primary_key: plan.primary_key,
75
+ searchable: plan.searchable,
76
+ vectors: plan.vectors,
77
+ schema_hash: plan.schema_hash,
78
+ fields: [...plan.fields.values()].map((planned) => ({
79
+ path: planned.path,
80
+ kind: planned.kind,
81
+ is_array: planned.is_array,
82
+ is_date: planned.is_date,
83
+ dimensions: planned.dimensions,
84
+ field: planned.field,
85
+ })),
86
+ });
87
+ }
88
+ deserializePlan(raw) {
89
+ const data = typeof raw === "string" ? JSON.parse(raw) : raw;
90
+ const fields = new Map(data.fields.map((entry) => [
91
+ entry.path,
92
+ {
93
+ path: entry.path,
94
+ kind: entry.kind,
95
+ is_array: entry.is_array,
96
+ is_date: entry.is_date,
97
+ dimensions: entry.dimensions,
98
+ field: entry.field,
99
+ },
100
+ ]));
101
+ return {
102
+ primary_key: data.primary_key,
103
+ searchable: data.searchable ?? [],
104
+ vectors: data.vectors ??
105
+ [...fields.values()]
106
+ .filter((f) => f.kind === "vector")
107
+ .map((f) => f.path),
108
+ schema_hash: data.schema_hash,
109
+ fields,
110
+ };
111
+ }
112
+ async getCatalog(name, manager = this.manager_) {
113
+ const rows = (await manager.execute(`SELECT * FROM "${utils_2.CATALOG_TABLE}" WHERE "name" = ?`, [name]));
114
+ if (!rows.length) {
115
+ return null;
116
+ }
117
+ const row = rows[0];
118
+ return {
119
+ name: row.name,
120
+ table_name: row.table_name,
121
+ schema_hash: row.schema_hash,
122
+ plan: this.deserializePlan(row.plan),
123
+ document_count: Number(row.document_count),
124
+ created_at: row.created_at,
125
+ updated_at: row.updated_at,
126
+ };
127
+ }
128
+ async createDocumentTable(table, plan, manager, extensions) {
129
+ const vectorColumns = plan.vectors
130
+ .map((path) => {
131
+ const planned = plan.fields.get(path);
132
+ const col = (0, utils_2.vectorColumnName)(path);
133
+ return `"${col}" vector(${planned.dimensions})`;
134
+ })
135
+ .join(",\n ");
136
+ await manager.execute(`
137
+ CREATE TABLE IF NOT EXISTS "${table}" (
138
+ "id" text NOT NULL,
139
+ "document" jsonb NOT NULL,
140
+ "indexed" jsonb NOT NULL DEFAULT '{}'::jsonb,
141
+ "search_vector" tsvector NOT NULL DEFAULT ''::tsvector,
142
+ "search_text" text NOT NULL DEFAULT '',
143
+ ${vectorColumns ? `${vectorColumns},` : ""}
144
+ "created_at" timestamptz NOT NULL DEFAULT now(),
145
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
146
+ CONSTRAINT "${table}_pkey" PRIMARY KEY ("id")
147
+ )
148
+ `);
149
+ // Add vector columns if the table already existed without them.
150
+ for (const path of plan.vectors) {
151
+ const planned = plan.fields.get(path);
152
+ const col = (0, utils_2.vectorColumnName)(path);
153
+ await manager.execute(`
154
+ ALTER TABLE "${table}"
155
+ ADD COLUMN IF NOT EXISTS "${col}" vector(${planned.dimensions})
156
+ `);
157
+ }
158
+ if (this.isLakebase_) {
159
+ // BM25 index is created lazily after documents exist (corpus stats).
160
+ await manager.execute(`DROP INDEX IF EXISTS "${table}_fts_idx"`);
161
+ }
162
+ else {
163
+ await manager.execute(`CREATE INDEX IF NOT EXISTS "${table}_fts_idx" ON "${table}" USING GIN ("search_vector")`);
164
+ }
165
+ if (extensions.pg_trgm) {
166
+ await manager.execute(`CREATE INDEX IF NOT EXISTS "${table}_trgm_idx" ON "${table}" USING GIN ("search_text" gin_trgm_ops)`);
167
+ }
168
+ await manager.execute(`CREATE INDEX IF NOT EXISTS "${table}_indexed_idx" ON "${table}" USING GIN ("indexed" jsonb_path_ops)`);
169
+ if (this.isLakebase_ && extensions.lakebase_vector) {
170
+ const opClass = (0, utils_2.vectorOpClass)(this.vectorDistance_);
171
+ for (const path of plan.vectors) {
172
+ const col = (0, utils_2.vectorColumnName)(path);
173
+ await manager.execute(`
174
+ CREATE INDEX IF NOT EXISTS "${table}_${col}_ann"
175
+ ON "${table}" USING lakebase_ann ("${col}" ${opClass})
176
+ `);
177
+ }
178
+ }
179
+ }
180
+ async ensureBm25Index(table, manager = this.manager_) {
181
+ if (!this.isLakebase_) {
182
+ return;
183
+ }
184
+ const name = (0, utils_2.bm25IndexName)(table);
185
+ await manager.execute(`
186
+ CREATE INDEX IF NOT EXISTS "${name}"
187
+ ON "${table}" USING lakebase_bm25 ("search_vector")
188
+ WITH (default_limit = 1000)
189
+ `);
190
+ }
191
+ async dropBm25Index(table, manager = this.manager_) {
192
+ if (!this.isLakebase_) {
193
+ return;
194
+ }
195
+ await manager.execute(`DROP INDEX IF EXISTS "${(0, utils_2.bm25IndexName)(table)}"`);
196
+ }
197
+ async dropDocumentTable(table, manager) {
198
+ await manager.execute(`DROP TABLE IF EXISTS "${table}" CASCADE`);
199
+ }
200
+ /**
201
+ * Renaming a table leaves its constraint and index names behind, and a later
202
+ * rebuild of the same shadow name would collide with them (or silently skip
203
+ * `CREATE INDEX IF NOT EXISTS`). Rename every table-derived artifact too.
204
+ */
205
+ async renameTableArtifacts(from, to, plan, manager) {
206
+ await manager.execute(`ALTER TABLE "${to}" RENAME CONSTRAINT "${from}_pkey" TO "${to}_pkey"`);
207
+ const suffixes = ["fts_idx", "trgm_idx", "indexed_idx", "bm25"];
208
+ for (const path of plan.vectors) {
209
+ suffixes.push(`${(0, utils_2.vectorColumnName)(path)}_ann`);
210
+ }
211
+ for (const suffix of suffixes) {
212
+ await manager.execute(`ALTER INDEX IF EXISTS "${from}_${suffix}" RENAME TO "${to}_${suffix}"`);
213
+ }
214
+ }
215
+ async adjustDocumentCount(name, delta, manager = this.manager_) {
216
+ if (!delta) {
217
+ return;
218
+ }
219
+ await manager.execute(`UPDATE "${utils_2.CATALOG_TABLE}"
220
+ SET "document_count" = GREATEST("document_count" + ?, 0), "updated_at" = now()
221
+ WHERE "name" = ?`, [delta, name]);
222
+ }
223
+ async setDocumentCount(name, count, manager = this.manager_) {
224
+ await manager.execute(`UPDATE "${utils_2.CATALOG_TABLE}"
225
+ SET "document_count" = ?, "updated_at" = now()
226
+ WHERE "name" = ?`, [count, name]);
227
+ }
228
+ async withTransaction(run) {
229
+ if (this.manager_.transactional) {
230
+ return await this.manager_.transactional(run);
231
+ }
232
+ return await run(this.manager_);
233
+ }
234
+ async upsertIndex({ index, }) {
235
+ (0, utils_2.assertIndexSupported)(index, this.engine_);
236
+ const extensions = await this.ensureExtensions();
237
+ const plan = (0, utils_2.buildIndexPlan)(index);
238
+ const table = (0, utils_2.tableNameForIndex)(index.physical_name);
239
+ const existing = await this.getCatalog(index.physical_name);
240
+ const rebuilt = !!existing && !(0, utils_2.sameSchema)(existing.plan, plan) && !!existing.table_name;
241
+ await this.withTransaction(async (manager) => {
242
+ if (rebuilt) {
243
+ await this.dropDocumentTable(existing.table_name, manager);
244
+ }
245
+ await this.createDocumentTable(table, plan, manager, extensions);
246
+ const planJson = this.serializePlan(plan);
247
+ if (existing) {
248
+ await manager.execute(`UPDATE "${utils_2.CATALOG_TABLE}"
249
+ SET "table_name" = ?, "schema_hash" = ?, "plan" = ?::jsonb,
250
+ ${rebuilt ? `"document_count" = 0,` : ""}
251
+ "updated_at" = now()
252
+ WHERE "name" = ?`, [table, plan.schema_hash, planJson, index.physical_name]);
253
+ }
254
+ else {
255
+ await manager.execute(`INSERT INTO "${utils_2.CATALOG_TABLE}"
256
+ ("name", "table_name", "schema_hash", "plan", "document_count")
257
+ VALUES (?, ?, ?, ?::jsonb, 0)`, [index.physical_name, table, plan.schema_hash, planJson]);
258
+ }
259
+ });
260
+ return this.task(index.physical_name);
261
+ }
262
+ async deleteIndex({ index, }) {
263
+ await this.ensureExtensions();
264
+ const existing = await this.getCatalog(index);
265
+ if (existing) {
266
+ await this.dropDocumentTable(existing.table_name, this.manager_);
267
+ await this.manager_.execute(`DELETE FROM "${utils_2.CATALOG_TABLE}" WHERE "name" = ?`, [index]);
268
+ }
269
+ return this.task(index);
270
+ }
271
+ async listIndexes() {
272
+ await this.ensureExtensions();
273
+ const rows = (await this.manager_.execute(`SELECT "name", "document_count", "created_at", "updated_at"
274
+ FROM "${utils_2.CATALOG_TABLE}"
275
+ ORDER BY "name"`));
276
+ return rows.map((row) => ({
277
+ name: row.name,
278
+ provider: PostgresSearchService.identifier,
279
+ document_count: Number(row.document_count),
280
+ created_at: row.created_at instanceof Date
281
+ ? row.created_at
282
+ : new Date(row.created_at),
283
+ updated_at: row.updated_at instanceof Date
284
+ ? row.updated_at
285
+ : new Date(row.updated_at),
286
+ }));
287
+ }
288
+ async swapIndex({ alias, index, }) {
289
+ await this.ensureExtensions();
290
+ const shadow = await this.getCatalog(index);
291
+ if (!shadow) {
292
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_FOUND, `The postgres search provider has no index "${index}" to swap from`);
293
+ }
294
+ const live = await this.getCatalog(alias);
295
+ const aliasTable = (0, utils_2.tableNameForIndex)(alias);
296
+ await this.withTransaction(async (manager) => {
297
+ if (live) {
298
+ await this.dropDocumentTable(live.table_name, manager);
299
+ await manager.execute(`DELETE FROM "${utils_2.CATALOG_TABLE}" WHERE "name" = ?`, [alias]);
300
+ }
301
+ if (shadow.table_name !== aliasTable) {
302
+ await manager.execute(`ALTER TABLE "${shadow.table_name}" RENAME TO "${aliasTable}"`);
303
+ await this.renameTableArtifacts(shadow.table_name, aliasTable, shadow.plan, manager);
304
+ }
305
+ await manager.execute(`DELETE FROM "${utils_2.CATALOG_TABLE}" WHERE "name" = ?`, [
306
+ index,
307
+ ]);
308
+ await manager.execute(`INSERT INTO "${utils_2.CATALOG_TABLE}"
309
+ ("name", "table_name", "schema_hash", "plan", "document_count", "created_at", "updated_at")
310
+ VALUES (?, ?, ?, ?::jsonb, ?, ?, now())`, [
311
+ alias,
312
+ aliasTable,
313
+ shadow.plan.schema_hash,
314
+ this.serializePlan(shadow.plan),
315
+ shadow.document_count,
316
+ shadow.created_at instanceof Date
317
+ ? shadow.created_at
318
+ : new Date(shadow.created_at),
319
+ ]);
320
+ if (shadow.document_count > 0) {
321
+ await this.ensureBm25Index(aliasTable, manager);
322
+ }
323
+ });
324
+ return this.task(alias);
325
+ }
326
+ buildSearchVectorSql(weightedParts, params) {
327
+ if (!weightedParts.length) {
328
+ return `''::tsvector`;
329
+ }
330
+ return weightedParts
331
+ .map((part) => {
332
+ params.push(part.text);
333
+ return `setweight(to_tsvector('${this.tsConfig_}', coalesce(?, '')), '${part.weight}')`;
334
+ })
335
+ .join(" || ");
336
+ }
337
+ async upsertDocuments({ index, documents, }) {
338
+ await this.ensureExtensions();
339
+ const catalog = await this.retrieve(index);
340
+ if (!documents.length) {
341
+ return this.task(index);
342
+ }
343
+ // Last write wins on duplicate ids — a multi-row upsert cannot touch the
344
+ // same id twice within one statement.
345
+ const projections = new Map();
346
+ for (const document of documents) {
347
+ const projection = (0, utils_2.projectIndexedDocument)(document, catalog.plan);
348
+ projections.set(projection.id, { document, projection });
349
+ }
350
+ const vectors = catalog.plan.vectors;
351
+ const entries = [...projections.values()];
352
+ await this.withTransaction(async (manager) => {
353
+ let inserted = 0;
354
+ for (let offset = 0; offset < entries.length; offset += UPSERT_CHUNK_SIZE) {
355
+ const chunk = entries.slice(offset, offset + UPSERT_CHUNK_SIZE);
356
+ const rowsSql = [];
357
+ const params = [];
358
+ for (const { document, projection } of chunk) {
359
+ params.push(projection.id, JSON.stringify(document), JSON.stringify(projection.indexed));
360
+ const vectorSql = this.buildSearchVectorSql(projection.weighted_parts, params);
361
+ params.push(projection.search_text);
362
+ const vectorPlaceholders = vectors.map((path) => {
363
+ const embedding = projection.vectors[path];
364
+ params.push(embedding ? `[${embedding.join(",")}]` : null);
365
+ return `?::vector`;
366
+ });
367
+ rowsSql.push(`(?, ?::jsonb, ?::jsonb, ${vectorSql}, ?${vectorPlaceholders.length
368
+ ? `, ${vectorPlaceholders.join(", ")}`
369
+ : ""}, now())`);
370
+ }
371
+ const vectorCols = vectors.map((path) => `"${(0, utils_2.vectorColumnName)(path)}"`);
372
+ const vectorUpdates = vectorCols.map((col) => `${col} = EXCLUDED.${col}`);
373
+ const rows = await manager.execute(`
374
+ INSERT INTO "${catalog.table_name}"
375
+ ("id", "document", "indexed", "search_vector", "search_text"${vectorCols.length ? `, ${vectorCols.join(", ")}` : ""}, "updated_at")
376
+ VALUES ${rowsSql.join(",\n")}
377
+ ON CONFLICT ("id") DO UPDATE SET
378
+ "document" = EXCLUDED."document",
379
+ "indexed" = EXCLUDED."indexed",
380
+ "search_vector" = EXCLUDED."search_vector",
381
+ "search_text" = EXCLUDED."search_text"${vectorUpdates.length ? `, ${vectorUpdates.join(", ")}` : ""},
382
+ "updated_at" = now()
383
+ RETURNING (xmax = 0)::int AS inserted
384
+ `, params);
385
+ for (const row of rows) {
386
+ inserted += Number(row.inserted ?? 0);
387
+ }
388
+ }
389
+ await this.adjustDocumentCount(index, inserted, manager);
390
+ });
391
+ // Create the BM25 index once documents exist (corpus stats need rows).
392
+ if (this.isLakebase_) {
393
+ await this.ensureBm25Index(catalog.table_name);
394
+ }
395
+ return this.task(index);
396
+ }
397
+ async deleteDocuments({ index, filters, }) {
398
+ await this.ensureExtensions();
399
+ const catalog = await this.retrieve(index);
400
+ await this.withTransaction(async (manager) => {
401
+ const ids = (0, utils_2.extractPrimaryKeyFilter)(filters, catalog.plan);
402
+ let deleted = [];
403
+ if (ids) {
404
+ if (ids.length) {
405
+ deleted = await manager.execute(`DELETE FROM "${catalog.table_name}" WHERE "id" = ANY(?::text[]) RETURNING "id"`, [ids]);
406
+ }
407
+ }
408
+ else {
409
+ const where = (0, utils_2.toWhereClause)(filters, catalog.plan);
410
+ if (where?.sql) {
411
+ deleted = await manager.execute(`DELETE FROM "${catalog.table_name}" WHERE ${where.sql} RETURNING "id"`, where.params);
412
+ }
413
+ }
414
+ await this.adjustDocumentCount(index, -deleted.length, manager);
415
+ });
416
+ return this.task(index);
417
+ }
418
+ async clearIndex({ index, }) {
419
+ await this.ensureExtensions();
420
+ const catalog = await this.retrieve(index);
421
+ await this.dropBm25Index(catalog.table_name);
422
+ await this.manager_.execute(`TRUNCATE TABLE "${catalog.table_name}"`);
423
+ await this.setDocumentCount(index, 0);
424
+ return this.task(index);
425
+ }
426
+ async search(input) {
427
+ const extensions = await this.ensureExtensions();
428
+ (0, utils_2.assertQuerySupported)(input, this.engine_);
429
+ const catalog = await this.retrieve(input.index.physical_name);
430
+ const plan = catalog.plan;
431
+ const options = input.search_options ?? {};
432
+ const skip = input.pagination?.skip ?? 0;
433
+ const take = input.pagination?.take ?? 20;
434
+ const started = Date.now();
435
+ for (const path of options.attributes_to_search_on ?? []) {
436
+ if (!plan.searchable.includes(path)) {
437
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Field "${path}" is not searchable on search index "${input.index.name}"`);
438
+ }
439
+ }
440
+ // Fails early on unknown / unsupported distinct fields.
441
+ this.resolveDistinct(options.distinct, plan, input.index.name);
442
+ const q = input.q?.trim();
443
+ const vectorOpts = options.vector;
444
+ const semanticRatio = vectorOpts?.semantic_ratio ??
445
+ (q && vectorOpts ? 0.5 : vectorOpts ? 1 : 0);
446
+ if (q && !plan.searchable.length && semanticRatio < 1) {
447
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Search index "${input.index.name}" has no searchable fields, so it cannot be queried by text`);
448
+ }
449
+ if (options.typo_tolerance && q) {
450
+ this.assertTypoToleranceAvailable(extensions);
451
+ }
452
+ let queryEmbedding;
453
+ if (vectorOpts && semanticRatio > 0) {
454
+ queryEmbedding = await this.resolveQueryEmbedding(vectorOpts, plan);
455
+ }
456
+ const useKeyword = !!q && semanticRatio < 1;
457
+ const useVector = !!queryEmbedding && semanticRatio > 0;
458
+ let hitRows;
459
+ let count;
460
+ if (useKeyword && useVector) {
461
+ ;
462
+ ({ hitRows, count } = await this.searchHybrid({
463
+ input,
464
+ catalog,
465
+ extensions,
466
+ queryEmbedding: queryEmbedding,
467
+ semanticRatio,
468
+ skip,
469
+ take,
470
+ }));
471
+ }
472
+ else if (useVector) {
473
+ ;
474
+ ({ hitRows, count } = await this.searchVector({
475
+ input,
476
+ catalog,
477
+ queryEmbedding: queryEmbedding,
478
+ skip,
479
+ take,
480
+ }));
481
+ }
482
+ else {
483
+ ;
484
+ ({ hitRows, count } = await this.searchKeyword({
485
+ input,
486
+ catalog,
487
+ extensions,
488
+ skip,
489
+ take,
490
+ }));
491
+ }
492
+ const facets = await this.resolveFacets({
493
+ table: catalog.table_name,
494
+ plan,
495
+ scopeWhere: this.buildFacetScope({
496
+ input,
497
+ catalog,
498
+ extensions,
499
+ useKeyword,
500
+ useVector,
501
+ }),
502
+ requests: options.facets,
503
+ });
504
+ return {
505
+ hits: hitRows.map((row) => {
506
+ const document = typeof row.document === "string"
507
+ ? JSON.parse(row.document)
508
+ : row.document;
509
+ return {
510
+ id: row.id,
511
+ score: options.include_score ? Number(row.score) : undefined,
512
+ document: (0, utils_2.projectDocument)(document, input.attributes_to_retrieve),
513
+ };
514
+ }),
515
+ facets,
516
+ metadata: {
517
+ skip,
518
+ take,
519
+ count: options.count === "none" ? null : count,
520
+ query: input.q,
521
+ processing_time_ms: Date.now() - started,
522
+ },
523
+ };
524
+ }
525
+ async resolveQueryEmbedding(vectorOpts, plan) {
526
+ const field = vectorOpts.field;
527
+ const planned = plan.fields.get(field);
528
+ if (!planned || planned.kind !== "vector") {
529
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Vector search field "${field}" is not a vector field on this index`);
530
+ }
531
+ if (vectorOpts.value) {
532
+ if (vectorOpts.value.length !== planned.dimensions) {
533
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Vector value for "${field}" expected ${planned.dimensions} dimensions, got ${vectorOpts.value.length}`);
534
+ }
535
+ return vectorOpts.value;
536
+ }
537
+ if (!vectorOpts.query) {
538
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `search_options.vector requires either "value" (embedding) or "query" (text to embed)`);
539
+ }
540
+ if (!this.embedder_) {
541
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_ARGUMENT, `search_options.vector.query requires an "embedder" function on the postgres search provider options`);
542
+ }
543
+ const embedding = await this.embedder_(vectorOpts.query);
544
+ if (embedding.length !== planned.dimensions) {
545
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Embedder returned ${embedding.length} dimensions for "${field}", expected ${planned.dimensions}`);
546
+ }
547
+ return embedding;
548
+ }
549
+ /**
550
+ * The keyword match/rank fragments, shared by the keyword path, the hybrid
551
+ * count and the facet scope so they always describe the same result set.
552
+ * Each fragment pushes its own placeholders when invoked.
553
+ */
554
+ keywordFragments(input) {
555
+ const { query, catalog, extensions } = input;
556
+ const plan = catalog.plan;
557
+ const options = query.search_options ?? {};
558
+ const q = query.q?.trim() || undefined;
559
+ const typo = !!(q && options.typo_tolerance);
560
+ const matchAny = options.match_strategy === "any";
561
+ const searchOn = options.attributes_to_search_on ?? plan.searchable;
562
+ const searchAllFields = searchOn.length === plan.searchable.length &&
563
+ searchOn.every((path) => plan.searchable.includes(path));
564
+ const tsConfig = this.tsConfig_;
565
+ const vectorExpr = searchAllFields
566
+ ? `"search_vector"`
567
+ : this.onTheFlyVectorExpr(searchOn, plan);
568
+ const textExpr = searchAllFields
569
+ ? `"search_text"`
570
+ : this.onTheFlyTextExpr(searchOn);
571
+ const useBm25 = this.isLakebase_ && !!q && searchAllFields;
572
+ const tsquery = (params) => {
573
+ params.push(q);
574
+ // plainto_tsquery ANDs every term; match_strategy "any" flips it to OR.
575
+ return matchAny
576
+ ? `replace(plainto_tsquery('${tsConfig}', ?)::text, ' & ', ' | ')::tsquery`
577
+ : `plainto_tsquery('${tsConfig}', ?)`;
578
+ };
579
+ const wordSim = (params) => {
580
+ params.push(q);
581
+ return (0, utils_2.wordSimilarityCall)(extensions, "?", textExpr);
582
+ };
583
+ const rankSql = (params) => {
584
+ if (!q) {
585
+ return `0::float4`;
586
+ }
587
+ let base;
588
+ if (useBm25) {
589
+ params.push(q);
590
+ const bm25 = (0, utils_2.bm25IndexName)(catalog.table_name);
591
+ // Negative BM25 → negate so higher scores are better (API convention).
592
+ base = `-1 * (${vectorExpr} <@> to_bm25query(to_tsvector('${tsConfig}', ?), '${bm25}'::regclass))`;
593
+ }
594
+ else {
595
+ base = `ts_rank_cd(${vectorExpr}, ${tsquery(params)})`;
596
+ }
597
+ return typo ? `GREATEST(${base}, ${wordSim(params)})` : base;
598
+ };
599
+ const matchSql = q
600
+ ? (params) => {
601
+ const match = `${vectorExpr} @@ ${tsquery(params)}`;
602
+ return typo ? `(${match} OR ${wordSim(params)} > 0.3)` : match;
603
+ }
604
+ : undefined;
605
+ return { q, needsBm25Limit: useBm25, rankSql, matchSql };
606
+ }
607
+ async searchKeyword(input) {
608
+ const { input: query, catalog, extensions, skip, take } = input;
609
+ const plan = catalog.plan;
610
+ const options = query.search_options ?? {};
611
+ const filterWhere = (0, utils_2.toWhereClause)(query.filters, plan);
612
+ const fragments = this.keywordFragments({ query, catalog, extensions });
613
+ const conditionsSql = (params) => {
614
+ const conditions = [];
615
+ if (fragments.matchSql) {
616
+ conditions.push(fragments.matchSql(params));
617
+ }
618
+ if (filterWhere?.sql) {
619
+ conditions.push(`(${filterWhere.sql})`);
620
+ params.push(...filterWhere.params);
621
+ }
622
+ return conditions;
623
+ };
624
+ const prelude = fragments.needsBm25Limit
625
+ ? async (manager) => {
626
+ const bm25 = (0, utils_2.bm25IndexName)(catalog.table_name);
627
+ const [row] = await manager.execute(`SELECT to_regclass(?) IS NOT NULL AS exists`, [bm25]);
628
+ if (!row?.exists) {
629
+ await this.ensureBm25Index(catalog.table_name, manager);
630
+ }
631
+ await manager.execute(`SELECT set_config('lakebase_bm25.default_limit', ?, true)`, [String(take + skip)]);
632
+ }
633
+ : undefined;
634
+ return await this.executeQuery({
635
+ table: catalog.table_name,
636
+ rankSql: fragments.rankSql,
637
+ conditionsSql,
638
+ orderSql: this.resolveOrderBy(query, plan, !!fragments.q),
639
+ minScore: options.min_score,
640
+ distinctExpr: this.resolveDistinct(options.distinct, plan, query.index.name),
641
+ count: options.count !== "none",
642
+ take,
643
+ skip,
644
+ prelude,
645
+ });
646
+ }
647
+ async searchVector(input) {
648
+ const { input: query, catalog, queryEmbedding, skip, take } = input;
649
+ const options = query.search_options ?? {};
650
+ const field = options.vector?.field;
651
+ if (!field) {
652
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `search_options.vector.field is required for vector search`);
653
+ }
654
+ const col = (0, utils_2.vectorColumnName)(field);
655
+ const op = (0, utils_2.vectorDistanceOperator)(this.vectorDistance_);
656
+ const filterWhere = (0, utils_2.toWhereClause)(query.filters, catalog.plan);
657
+ const embeddingLiteral = `[${queryEmbedding.join(",")}]`;
658
+ const rankSql = (params) => {
659
+ params.push(embeddingLiteral);
660
+ // Distance → relevance (higher is better).
661
+ return `(1.0 / (1.0 + ("${col}" ${op} ?::vector)))`;
662
+ };
663
+ const conditionsSql = (params) => {
664
+ const conditions = [`"${col}" IS NOT NULL`];
665
+ if (filterWhere?.sql) {
666
+ conditions.push(`(${filterWhere.sql})`);
667
+ params.push(...filterWhere.params);
668
+ }
669
+ return conditions;
670
+ };
671
+ const hasCustomOrder = !!Object.keys(query.pagination?.order ?? {}).length;
672
+ // The default order is distance ASC; expressed directly (instead of via
673
+ // the score alias) so the ANN index can serve the scan.
674
+ const fastOrderSql = hasCustomOrder
675
+ ? undefined
676
+ : (params) => {
677
+ params.push(embeddingLiteral);
678
+ return `"${col}" ${op} ?::vector ASC, "id" ASC`;
679
+ };
680
+ return await this.executeQuery({
681
+ table: catalog.table_name,
682
+ rankSql,
683
+ conditionsSql,
684
+ orderSql: this.resolveOrderBy(query, catalog.plan, true),
685
+ fastOrderSql,
686
+ minScore: options.min_score,
687
+ distinctExpr: this.resolveDistinct(options.distinct, catalog.plan, query.index.name),
688
+ count: options.count !== "none",
689
+ take,
690
+ skip,
691
+ });
692
+ }
693
+ async searchHybrid(input) {
694
+ const { input: query, catalog, extensions, queryEmbedding, semanticRatio, skip, take, } = input;
695
+ const options = query.search_options ?? {};
696
+ const plan = catalog.plan;
697
+ const order = query.pagination?.order ?? {};
698
+ const fieldOrderKeys = Object.keys(order).filter((key) => key !== "_score");
699
+ if (fieldOrderKeys.length) {
700
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, `Hybrid search results are rank-fused, so they can only be ordered by "_score" (got "${fieldOrderKeys.join(", ")}"). Drop the vector option or the field order.`);
701
+ }
702
+ // Arms are relevance-ranked candidate lists; page-level options (distinct,
703
+ // min_score, count, order) apply to the fused list instead.
704
+ const candidateLimit = Math.max(take + skip, 40) * 2;
705
+ const armQuery = {
706
+ ...query,
707
+ pagination: {},
708
+ search_options: {
709
+ ...options,
710
+ min_score: undefined,
711
+ distinct: undefined,
712
+ count: "none",
713
+ },
714
+ };
715
+ const [keyword, vector] = await Promise.all([
716
+ this.searchKeyword({
717
+ input: armQuery,
718
+ catalog,
719
+ extensions,
720
+ skip: 0,
721
+ take: candidateLimit,
722
+ }),
723
+ this.searchVector({
724
+ input: armQuery,
725
+ catalog,
726
+ queryEmbedding,
727
+ skip: 0,
728
+ take: candidateLimit,
729
+ }),
730
+ ]);
731
+ // Reciprocal Rank Fusion over keyword + vector candidate lists. Fused
732
+ // scores live on the RRF scale (≤ ~1/60 per arm), which is also the scale
733
+ // `min_score` is compared against for hybrid queries.
734
+ const k = 60;
735
+ const keywordWeight = 1 - semanticRatio;
736
+ const vectorWeight = semanticRatio;
737
+ const scores = new Map();
738
+ keyword.hitRows.forEach((row, rank) => {
739
+ scores.set(row.id, {
740
+ id: row.id,
741
+ document: row.document,
742
+ indexed: row.indexed,
743
+ score: keywordWeight * (1 / (k + rank + 1)),
744
+ });
745
+ });
746
+ vector.hitRows.forEach((row, rank) => {
747
+ const rrf = vectorWeight * (1 / (k + rank + 1));
748
+ const existing = scores.get(row.id);
749
+ if (existing) {
750
+ existing.score += rrf;
751
+ }
752
+ else {
753
+ scores.set(row.id, {
754
+ id: row.id,
755
+ document: row.document,
756
+ indexed: row.indexed,
757
+ score: rrf,
758
+ });
759
+ }
760
+ });
761
+ let ranked = [...scores.values()].sort((a, b) => b.score - a.score);
762
+ if (options.distinct) {
763
+ const seen = new Set();
764
+ ranked = ranked.filter((row) => {
765
+ const key = this.readIndexedValue(row.indexed, options.distinct);
766
+ if (key === undefined) {
767
+ return true;
768
+ }
769
+ if (seen.has(key)) {
770
+ return false;
771
+ }
772
+ seen.add(key);
773
+ return true;
774
+ });
775
+ }
776
+ if (options.min_score !== undefined) {
777
+ ranked = ranked.filter((row) => row.score >= options.min_score);
778
+ }
779
+ if (order._score === "ASC") {
780
+ ranked.reverse();
781
+ }
782
+ const page = ranked.slice(skip, skip + take);
783
+ // The fused list is a bounded candidate window, so its length is not a
784
+ // usable total — count the union of both arms' match sets instead.
785
+ let count = null;
786
+ if (options.count !== "none") {
787
+ const fragments = this.keywordFragments({ query, catalog, extensions });
788
+ const col = (0, utils_2.vectorColumnName)(options.vector.field);
789
+ const filterWhere = (0, utils_2.toWhereClause)(query.filters, plan);
790
+ const params = [];
791
+ const conditions = [
792
+ `(${fragments.matchSql(params)} OR "${col}" IS NOT NULL)`,
793
+ ];
794
+ if (filterWhere?.sql) {
795
+ conditions.push(`(${filterWhere.sql})`);
796
+ params.push(...filterWhere.params);
797
+ }
798
+ const distinctExpr = this.resolveDistinct(options.distinct, plan, query.index.name);
799
+ const countExpr = distinctExpr
800
+ ? `COUNT(DISTINCT ${distinctExpr})`
801
+ : `COUNT(*)`;
802
+ const rows = await this.manager_.execute(`SELECT ${countExpr}::int AS count FROM "${catalog.table_name}" WHERE ${conditions.join(" AND ")}`, params);
803
+ count = Number(rows[0]?.count ?? 0);
804
+ }
805
+ return { hitRows: page, count };
806
+ }
807
+ /**
808
+ * Runs the hits + count statements for a single-arm search. Wraps the base
809
+ * select in subqueries when `min_score` or `distinct` need to see the score
810
+ * as a real column.
811
+ */
812
+ async executeQuery(parts) {
813
+ const { table, minScore, distinctExpr, take, skip } = parts;
814
+ const wrap = minScore !== undefined || !!distinctExpr;
815
+ const buildBase = (params) => {
816
+ const rank = parts.rankSql(params);
817
+ const conditions = parts.conditionsSql(params);
818
+ const whereSql = conditions.length
819
+ ? `WHERE ${conditions.join(" AND ")}`
820
+ : "";
821
+ return `SELECT "id", "document", "indexed", ${rank} AS score FROM "${table}" ${whereSql}`;
822
+ };
823
+ const buildWrappedFrom = (params) => {
824
+ let from = `(${buildBase(params)}) ranked`;
825
+ if (distinctExpr) {
826
+ // One representative row per value, picked by the requested order.
827
+ from = `(
828
+ SELECT ranked.*, ROW_NUMBER() OVER (
829
+ PARTITION BY ${distinctExpr} ORDER BY ${parts.orderSql}
830
+ ) AS __rn
831
+ FROM ${from}
832
+ ) deduped`;
833
+ }
834
+ return from;
835
+ };
836
+ const wrappedConditions = () => {
837
+ const outer = [];
838
+ if (distinctExpr) {
839
+ outer.push(`__rn = 1`);
840
+ }
841
+ if (minScore !== undefined) {
842
+ outer.push(`score >= ?`);
843
+ }
844
+ return outer;
845
+ };
846
+ let hitsSql;
847
+ const hitsParams = [];
848
+ if (!wrap) {
849
+ const base = buildBase(hitsParams);
850
+ const orderSql = parts.fastOrderSql
851
+ ? parts.fastOrderSql(hitsParams)
852
+ : parts.orderSql;
853
+ hitsSql = `${base} ORDER BY ${orderSql} LIMIT ? OFFSET ?`;
854
+ hitsParams.push(take, skip);
855
+ }
856
+ else {
857
+ const from = buildWrappedFrom(hitsParams);
858
+ const outer = wrappedConditions();
859
+ hitsSql = `
860
+ SELECT * FROM ${from}
861
+ ${outer.length ? `WHERE ${outer.join(" AND ")}` : ""}
862
+ ORDER BY ${parts.orderSql}
863
+ LIMIT ? OFFSET ?
864
+ `;
865
+ if (minScore !== undefined) {
866
+ hitsParams.push(minScore);
867
+ }
868
+ hitsParams.push(take, skip);
869
+ }
870
+ let countSql = null;
871
+ const countParams = [];
872
+ if (parts.count) {
873
+ if (!wrap) {
874
+ const conditions = parts.conditionsSql(countParams);
875
+ countSql = `SELECT COUNT(*)::int AS count FROM "${table}" ${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""}`;
876
+ }
877
+ else {
878
+ const from = buildWrappedFrom(countParams);
879
+ const outer = wrappedConditions();
880
+ countSql = `
881
+ SELECT COUNT(*)::int AS count FROM ${from}
882
+ ${outer.length ? `WHERE ${outer.join(" AND ")}` : ""}
883
+ `;
884
+ if (minScore !== undefined) {
885
+ countParams.push(minScore);
886
+ }
887
+ }
888
+ }
889
+ const run = async (manager) => {
890
+ if (parts.prelude) {
891
+ await parts.prelude(manager);
892
+ }
893
+ const hitRows = await manager.execute(hitsSql, hitsParams);
894
+ const count = countSql
895
+ ? Number((await manager.execute(countSql, countParams))[0]?.count ?? 0)
896
+ : null;
897
+ return { hitRows, count };
898
+ };
899
+ // set_config in the prelude is transaction-local, so it needs the hits
900
+ // query inside the same transaction. Plain reads skip the overhead.
901
+ return parts.prelude ? await this.withTransaction(run) : await run(this.manager_);
902
+ }
903
+ async searchMany(inputs) {
904
+ return await Promise.all(inputs.map((input) => this.search(input)));
905
+ }
906
+ /**
907
+ * The WHERE fragment facets are scoped by: the filters plus the same match
908
+ * predicate as the hits, so facet counts describe the result set.
909
+ */
910
+ buildFacetScope(input) {
911
+ const { input: query, catalog, extensions, useKeyword, useVector } = input;
912
+ const params = [];
913
+ const conditions = [];
914
+ const matchSql = useKeyword
915
+ ? this.keywordFragments({ query, catalog, extensions }).matchSql
916
+ : undefined;
917
+ const vectorCond = useVector
918
+ ? `"${(0, utils_2.vectorColumnName)(query.search_options.vector.field)}" IS NOT NULL`
919
+ : undefined;
920
+ if (matchSql && vectorCond) {
921
+ conditions.push(`(${matchSql(params)} OR ${vectorCond})`);
922
+ }
923
+ else if (matchSql) {
924
+ conditions.push(matchSql(params));
925
+ }
926
+ else if (vectorCond) {
927
+ conditions.push(vectorCond);
928
+ }
929
+ const filterWhere = (0, utils_2.toWhereClause)(query.filters, catalog.plan);
930
+ if (filterWhere?.sql) {
931
+ conditions.push(`(${filterWhere.sql})`);
932
+ params.push(...filterWhere.params);
933
+ }
934
+ return conditions.length
935
+ ? { sql: conditions.join(" AND "), params }
936
+ : undefined;
937
+ }
938
+ async resolveFacets(input) {
939
+ const requests = (0, utils_2.normalizeFacetRequests)(input.requests, input.plan);
940
+ if (!requests.length) {
941
+ return undefined;
942
+ }
943
+ const result = {};
944
+ for (const request of requests) {
945
+ const query = (0, utils_2.buildFacetQuery)({
946
+ table: input.table,
947
+ whereSql: input.scopeWhere?.sql,
948
+ whereParams: input.scopeWhere?.params ?? [],
949
+ request,
950
+ plan: input.plan,
951
+ });
952
+ const rows = await this.manager_.execute(query.sql, query.params);
953
+ result[request.field] = (0, utils_2.mapFacetResult)(request, rows);
954
+ }
955
+ return result;
956
+ }
957
+ onTheFlyTextExpr(paths) {
958
+ if (!paths.length) {
959
+ return `''`;
960
+ }
961
+ return paths
962
+ .map((path) => {
963
+ const jsonPath = `'{${path.split(".").join(",")}}'`;
964
+ return `coalesce(indexed#>>${jsonPath}, '')`;
965
+ })
966
+ .join(` || ' ' || `);
967
+ }
968
+ onTheFlyVectorExpr(paths, plan) {
969
+ if (!paths.length) {
970
+ return `''::tsvector`;
971
+ }
972
+ return paths
973
+ .map((path) => {
974
+ const planned = plan.fields.get(path);
975
+ const weight = planned ? (0, utils_2.weightLabel)(planned.field.searchable) : "D";
976
+ const jsonPath = `'{${path.split(".").join(",")}}'`;
977
+ return `setweight(to_tsvector('${this.tsConfig_}', coalesce(indexed#>>${jsonPath}, '')), '${weight}')`;
978
+ })
979
+ .join(" || ");
980
+ }
981
+ resolveOrderBy(input, plan, hasScore) {
982
+ const order = Object.entries(input.pagination?.order ?? {});
983
+ if (!order.length) {
984
+ return hasScore
985
+ ? `score DESC NULLS LAST, "id" ASC`
986
+ : `"id" ASC`;
987
+ }
988
+ const clauses = [];
989
+ for (const [property, direction] of order) {
990
+ const dir = direction === "ASC" ? "ASC" : "DESC";
991
+ if (property === "_score") {
992
+ clauses.push(`score ${dir} NULLS LAST`);
993
+ continue;
994
+ }
995
+ const planned = plan.fields.get(property);
996
+ if (!planned) {
997
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Cannot sort search index "${input.index.name}" by unknown field "${property}"`);
998
+ }
999
+ if (planned.is_array || planned.kind === "vector") {
1000
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, `The postgres search provider cannot sort by "${property}"`);
1001
+ }
1002
+ const jsonPath = `'{${property.split(".").join(",")}}'`;
1003
+ if (planned.kind === "number") {
1004
+ clauses.push(`(indexed#>>${jsonPath})::float8 ${dir} NULLS LAST`);
1005
+ }
1006
+ else if (planned.kind === "boolean") {
1007
+ clauses.push(`(indexed#>>${jsonPath})::boolean ${dir} NULLS LAST`);
1008
+ }
1009
+ else {
1010
+ clauses.push(`indexed#>>${jsonPath} ${dir} NULLS LAST`);
1011
+ }
1012
+ }
1013
+ return clauses.join(", ");
1014
+ }
1015
+ /**
1016
+ * Validates `search_options.distinct` and returns the SQL expression to
1017
+ * deduplicate on, or undefined when not requested.
1018
+ */
1019
+ resolveDistinct(distinct, plan, indexName) {
1020
+ if (!distinct) {
1021
+ return undefined;
1022
+ }
1023
+ const planned = plan.fields.get(distinct);
1024
+ if (!planned) {
1025
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Cannot deduplicate search index "${indexName}" by unknown field "${distinct}"`);
1026
+ }
1027
+ if (planned.is_array || planned.kind === "vector") {
1028
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, `The postgres search provider cannot deduplicate by "${distinct}"`);
1029
+ }
1030
+ return `indexed#>>'{${distinct.split(".").join(",")}}'`;
1031
+ }
1032
+ /** Reads a dotted path from an `indexed` jsonb value returned by a query. */
1033
+ readIndexedValue(indexed, path) {
1034
+ const parsed = typeof indexed === "string" ? JSON.parse(indexed) : indexed;
1035
+ const value = path
1036
+ .split(".")
1037
+ .reduce((acc, segment) => (acc == null ? undefined : acc[segment]), parsed);
1038
+ return value == null ? undefined : String(value);
1039
+ }
1040
+ async retrieve(index) {
1041
+ const catalog = await this.getCatalog(index);
1042
+ if (!catalog) {
1043
+ const known = await this.listIndexes();
1044
+ throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_FOUND, `The postgres search provider has no index "${index}". Known indexes: ${known.map((entry) => entry.name).join(", ") || "(none)"}`);
1045
+ }
1046
+ return catalog;
1047
+ }
1048
+ task(index) {
1049
+ return { index, status: "succeeded" };
1050
+ }
1051
+ }
1052
+ exports.PostgresSearchService = PostgresSearchService;
1053
+ PostgresSearchService.identifier = "search-postgres";
1054
+ //# sourceMappingURL=postgres-search.js.map