@unifiedcommerce/adapter-pg-search 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@unifiedcommerce/adapter-pg-search",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
7
- "types": "./dist/index.d.ts",
8
- "default": "./dist/index.js"
7
+ "bun": "./src/index.ts",
8
+ "import": "./dist/index.js",
9
+ "types": "./src/index.ts"
9
10
  }
10
11
  },
11
12
  "scripts": {
@@ -30,7 +31,6 @@
30
31
  },
31
32
  "files": [
32
33
  "dist",
33
- "src",
34
34
  "README.md"
35
35
  ]
36
36
  }
package/src/index.ts DELETED
@@ -1,318 +0,0 @@
1
- import {
2
- Err,
3
- Ok,
4
- type Result,
5
- type SearchAdapter,
6
- type SearchDocument,
7
- type SearchQueryParams,
8
- type SearchQueryResult,
9
- type SearchSuggestParams,
10
- } from "@unifiedcommerce/core";
11
-
12
- export interface PgSearchQueryResultRow {
13
- [key: string]: unknown;
14
- }
15
-
16
- export interface PgSearchAdapterOptions {
17
- query: (sql: string, params: unknown[]) => Promise<{ rows: PgSearchQueryResultRow[] }>;
18
- tableName?: string;
19
- dictionary?: string;
20
- }
21
-
22
- function safeIdentifier(value: string): string {
23
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
24
- throw new Error(`Invalid SQL identifier: ${value}`);
25
- }
26
- return value;
27
- }
28
-
29
- function parseCategories(value: unknown): string[] {
30
- if (Array.isArray(value)) {
31
- return value.filter((item): item is string => typeof item === "string");
32
- }
33
-
34
- if (typeof value === "string") {
35
- try {
36
- const parsed = JSON.parse(value) as unknown;
37
- if (Array.isArray(parsed)) {
38
- return parsed.filter((item): item is string => typeof item === "string");
39
- }
40
- } catch {
41
- return [];
42
- }
43
- }
44
-
45
- return [];
46
- }
47
-
48
- function parseBrands(value: unknown): string[] {
49
- if (Array.isArray(value)) {
50
- return value.filter((item): item is string => typeof item === "string");
51
- }
52
-
53
- if (typeof value === "string") {
54
- try {
55
- const parsed = JSON.parse(value) as unknown;
56
- if (Array.isArray(parsed)) {
57
- return parsed.filter((item): item is string => typeof item === "string");
58
- }
59
- } catch {
60
- return [];
61
- }
62
- }
63
-
64
- return [];
65
- }
66
-
67
- function toDocument(row: PgSearchQueryResultRow): SearchDocument {
68
- return {
69
- id: String(row.id ?? ""),
70
- type: String(row.type ?? ""),
71
- slug: String(row.slug ?? ""),
72
- title: String(row.title ?? ""),
73
- ...(row.description ? { description: String(row.description) } : {}),
74
- ...(row.status ? { status: String(row.status) } : {}),
75
- categories: parseCategories(row.categories),
76
- brands: parseBrands(row.brands),
77
- text: String(row.text ?? ""),
78
- ...(row.payload && typeof row.payload === "object" ? { payload: row.payload as Record<string, unknown> } : {}),
79
- };
80
- }
81
-
82
- function buildWhere(
83
- params: SearchQueryParams,
84
- dictionary: string,
85
- ): { sql: string; values: unknown[] } {
86
- const clauses: string[] = [];
87
- const values: unknown[] = [];
88
-
89
- if (params.query.trim().length > 0) {
90
- values.push(params.query);
91
- clauses.push(`to_tsvector('${dictionary}', text) @@ plainto_tsquery('${dictionary}', $${values.length})`);
92
- }
93
-
94
- if (params.filters?.type) {
95
- values.push(params.filters.type);
96
- clauses.push(`type = $${values.length}`);
97
- }
98
-
99
- if (params.filters?.status) {
100
- values.push(params.filters.status);
101
- clauses.push(`status = $${values.length}`);
102
- }
103
-
104
- if (params.filters?.category) {
105
- values.push(params.filters.category);
106
- clauses.push(`$${values.length} = ANY(categories)`);
107
- }
108
-
109
- if (params.filters?.brand) {
110
- values.push(params.filters.brand);
111
- clauses.push(`$${values.length} = ANY(brands)`);
112
- }
113
-
114
- return {
115
- sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
116
- values,
117
- };
118
- }
119
-
120
- function computeFacets(documents: SearchDocument[], requested?: string[]): Record<string, Record<string, number>> {
121
- const facets = requested && requested.length > 0 ? requested : ["type", "status", "category", "brand"];
122
- const output: Record<string, Record<string, number>> = {};
123
-
124
- if (facets.includes("type")) {
125
- output.type = {};
126
- for (const document of documents) {
127
- output.type[document.type] = (output.type[document.type] ?? 0) + 1;
128
- }
129
- }
130
-
131
- if (facets.includes("status")) {
132
- output.status = {};
133
- for (const document of documents) {
134
- const status = document.status ?? "unknown";
135
- output.status[status] = (output.status[status] ?? 0) + 1;
136
- }
137
- }
138
-
139
- if (facets.includes("category") || facets.includes("categories")) {
140
- output.category = {};
141
- for (const document of documents) {
142
- for (const category of document.categories) {
143
- output.category[category] = (output.category[category] ?? 0) + 1;
144
- }
145
- }
146
- }
147
-
148
- if (facets.includes("brand") || facets.includes("brands")) {
149
- output.brand = {};
150
- for (const document of documents) {
151
- for (const brand of document.brands) {
152
- output.brand[brand] = (output.brand[brand] ?? 0) + 1;
153
- }
154
- }
155
- }
156
-
157
- return output;
158
- }
159
-
160
- export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter {
161
- const table = safeIdentifier(options.tableName ?? "search_index");
162
- const dictionary = options.dictionary ?? "english";
163
-
164
- return {
165
- providerId: "pg-search",
166
-
167
- async index(documents: SearchDocument[]): Promise<Result<void>> {
168
- try {
169
- if (documents.length === 0) return Ok(undefined);
170
-
171
- for (const document of documents) {
172
- await options.query(
173
- `INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, payload)
174
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
175
- ON CONFLICT (id)
176
- DO UPDATE SET
177
- type = EXCLUDED.type,
178
- slug = EXCLUDED.slug,
179
- title = EXCLUDED.title,
180
- description = EXCLUDED.description,
181
- status = EXCLUDED.status,
182
- categories = EXCLUDED.categories,
183
- brands = EXCLUDED.brands,
184
- text = EXCLUDED.text,
185
- payload = EXCLUDED.payload`,
186
- [
187
- document.id,
188
- document.type,
189
- document.slug,
190
- document.title,
191
- document.description ?? null,
192
- document.status ?? null,
193
- document.categories,
194
- document.brands,
195
- document.text,
196
- JSON.stringify(document.payload ?? {}),
197
- ],
198
- );
199
- }
200
-
201
- return Ok(undefined);
202
- } catch (error) {
203
- return Err({
204
- code: "PG_SEARCH_INDEX_FAILED",
205
- message: error instanceof Error ? error.message : "pg-search indexing failed.",
206
- });
207
- }
208
- },
209
-
210
- async remove(ids: string[]): Promise<Result<void>> {
211
- try {
212
- if (ids.length === 0) return Ok(undefined);
213
- await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
214
- return Ok(undefined);
215
- } catch (error) {
216
- return Err({
217
- code: "PG_SEARCH_DELETE_FAILED",
218
- message: error instanceof Error ? error.message : "pg-search delete failed.",
219
- });
220
- }
221
- },
222
-
223
- async search(params: SearchQueryParams): Promise<Result<SearchQueryResult>> {
224
- try {
225
- const page = Math.max(1, params.page ?? 1);
226
- const limit = Math.max(1, Math.min(100, params.limit ?? 20));
227
- const offset = (page - 1) * limit;
228
-
229
- const where = buildWhere(params, dictionary);
230
- const scoreExpr = params.query.trim().length > 0
231
- ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
232
- : "0";
233
-
234
- const rows = await options.query(
235
- `SELECT id, type, slug, title, description, status, categories, brands, text, payload, ${scoreExpr} AS score
236
- FROM ${table}
237
- ${where.sql}
238
- ORDER BY score DESC, title ASC
239
- LIMIT $${where.values.length + 1}
240
- OFFSET $${where.values.length + 2}`,
241
- [...where.values, limit, offset],
242
- );
243
-
244
- const countRows = await options.query(
245
- `SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`,
246
- where.values,
247
- );
248
-
249
- const facetRows = await options.query(
250
- `SELECT id, type, slug, title, description, status, categories, brands, text, payload
251
- FROM ${table}
252
- ${where.sql}`,
253
- where.values,
254
- );
255
-
256
- const documents = facetRows.rows.map((row) => toDocument(row));
257
- const hits = rows.rows.map((row) => ({
258
- id: String(row.id ?? ""),
259
- score: Number(row.score ?? 0),
260
- document: toDocument(row),
261
- }));
262
-
263
- return Ok({
264
- hits,
265
- total: Number(countRows.rows[0]?.total ?? hits.length),
266
- page,
267
- limit,
268
- facets: computeFacets(documents, params.facets),
269
- });
270
- } catch (error) {
271
- return Err({
272
- code: "PG_SEARCH_QUERY_FAILED",
273
- message: error instanceof Error ? error.message : "pg-search query failed.",
274
- });
275
- }
276
- },
277
-
278
- async suggest(params: SearchSuggestParams): Promise<Result<string[]>> {
279
- try {
280
- const limit = Math.max(1, Math.min(25, params.limit ?? 10));
281
- const prefix = params.prefix.trim().toLowerCase();
282
-
283
- if (!prefix) return Ok([]);
284
-
285
- const conditions = ["LOWER(title) LIKE $1"];
286
- const values: unknown[] = [`${prefix}%`];
287
-
288
- if (params.type) {
289
- values.push(params.type);
290
- conditions.push(`type = $${values.length}`);
291
- }
292
-
293
- values.push(limit);
294
-
295
- const rows = await options.query(
296
- `SELECT DISTINCT title
297
- FROM ${table}
298
- WHERE ${conditions.join(" AND ")}
299
- ORDER BY title ASC
300
- LIMIT $${values.length}`,
301
- values,
302
- );
303
-
304
- const suggestions = rows.rows
305
- .map((row) => String(row.title ?? ""))
306
- .filter(Boolean)
307
- .slice(0, limit);
308
-
309
- return Ok(suggestions);
310
- } catch (error) {
311
- return Err({
312
- code: "PG_SEARCH_SUGGEST_FAILED",
313
- message: error instanceof Error ? error.message : "pg-search suggest failed.",
314
- });
315
- }
316
- },
317
- };
318
- }