@unifiedcommerce/adapter-pg-search 0.0.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,13 @@
1
+ import { type SearchAdapter } from "@unifiedcommerce/core";
2
+ export interface PgSearchQueryResultRow {
3
+ [key: string]: unknown;
4
+ }
5
+ export interface PgSearchAdapterOptions {
6
+ query: (sql: string, params: unknown[]) => Promise<{
7
+ rows: PgSearchQueryResultRow[];
8
+ }>;
9
+ tableName?: string;
10
+ dictionary?: string;
11
+ }
12
+ export declare function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter;
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAKnB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,sBAAsB,EAAE,CAAA;KAAE,CAAC,CAAC;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4ID,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,aAAa,CA8J9E"}
@@ -0,0 +1,248 @@
1
+ import { Err, Ok, } from "@unifiedcommerce/core";
2
+ function safeIdentifier(value) {
3
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
4
+ throw new Error(`Invalid SQL identifier: ${value}`);
5
+ }
6
+ return value;
7
+ }
8
+ function parseCategories(value) {
9
+ if (Array.isArray(value)) {
10
+ return value.filter((item) => typeof item === "string");
11
+ }
12
+ if (typeof value === "string") {
13
+ try {
14
+ const parsed = JSON.parse(value);
15
+ if (Array.isArray(parsed)) {
16
+ return parsed.filter((item) => typeof item === "string");
17
+ }
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ return [];
24
+ }
25
+ function parseBrands(value) {
26
+ if (Array.isArray(value)) {
27
+ return value.filter((item) => typeof item === "string");
28
+ }
29
+ if (typeof value === "string") {
30
+ try {
31
+ const parsed = JSON.parse(value);
32
+ if (Array.isArray(parsed)) {
33
+ return parsed.filter((item) => typeof item === "string");
34
+ }
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
40
+ return [];
41
+ }
42
+ function toDocument(row) {
43
+ return {
44
+ id: String(row.id ?? ""),
45
+ type: String(row.type ?? ""),
46
+ slug: String(row.slug ?? ""),
47
+ title: String(row.title ?? ""),
48
+ ...(row.description ? { description: String(row.description) } : {}),
49
+ ...(row.status ? { status: String(row.status) } : {}),
50
+ categories: parseCategories(row.categories),
51
+ brands: parseBrands(row.brands),
52
+ text: String(row.text ?? ""),
53
+ ...(row.payload && typeof row.payload === "object" ? { payload: row.payload } : {}),
54
+ };
55
+ }
56
+ function buildWhere(params, dictionary) {
57
+ const clauses = [];
58
+ const values = [];
59
+ if (params.query.trim().length > 0) {
60
+ values.push(params.query);
61
+ clauses.push(`to_tsvector('${dictionary}', text) @@ plainto_tsquery('${dictionary}', $${values.length})`);
62
+ }
63
+ if (params.filters?.type) {
64
+ values.push(params.filters.type);
65
+ clauses.push(`type = $${values.length}`);
66
+ }
67
+ if (params.filters?.status) {
68
+ values.push(params.filters.status);
69
+ clauses.push(`status = $${values.length}`);
70
+ }
71
+ if (params.filters?.category) {
72
+ values.push(params.filters.category);
73
+ clauses.push(`$${values.length} = ANY(categories)`);
74
+ }
75
+ if (params.filters?.brand) {
76
+ values.push(params.filters.brand);
77
+ clauses.push(`$${values.length} = ANY(brands)`);
78
+ }
79
+ return {
80
+ sql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
81
+ values,
82
+ };
83
+ }
84
+ function computeFacets(documents, requested) {
85
+ const facets = requested && requested.length > 0 ? requested : ["type", "status", "category", "brand"];
86
+ const output = {};
87
+ if (facets.includes("type")) {
88
+ output.type = {};
89
+ for (const document of documents) {
90
+ output.type[document.type] = (output.type[document.type] ?? 0) + 1;
91
+ }
92
+ }
93
+ if (facets.includes("status")) {
94
+ output.status = {};
95
+ for (const document of documents) {
96
+ const status = document.status ?? "unknown";
97
+ output.status[status] = (output.status[status] ?? 0) + 1;
98
+ }
99
+ }
100
+ if (facets.includes("category") || facets.includes("categories")) {
101
+ output.category = {};
102
+ for (const document of documents) {
103
+ for (const category of document.categories) {
104
+ output.category[category] = (output.category[category] ?? 0) + 1;
105
+ }
106
+ }
107
+ }
108
+ if (facets.includes("brand") || facets.includes("brands")) {
109
+ output.brand = {};
110
+ for (const document of documents) {
111
+ for (const brand of document.brands) {
112
+ output.brand[brand] = (output.brand[brand] ?? 0) + 1;
113
+ }
114
+ }
115
+ }
116
+ return output;
117
+ }
118
+ export function pgSearchAdapter(options) {
119
+ const table = safeIdentifier(options.tableName ?? "search_index");
120
+ const dictionary = options.dictionary ?? "english";
121
+ return {
122
+ providerId: "pg-search",
123
+ async index(documents) {
124
+ try {
125
+ if (documents.length === 0)
126
+ return Ok(undefined);
127
+ for (const document of documents) {
128
+ await options.query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, payload)
129
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
130
+ ON CONFLICT (id)
131
+ DO UPDATE SET
132
+ type = EXCLUDED.type,
133
+ slug = EXCLUDED.slug,
134
+ title = EXCLUDED.title,
135
+ description = EXCLUDED.description,
136
+ status = EXCLUDED.status,
137
+ categories = EXCLUDED.categories,
138
+ brands = EXCLUDED.brands,
139
+ text = EXCLUDED.text,
140
+ payload = EXCLUDED.payload`, [
141
+ document.id,
142
+ document.type,
143
+ document.slug,
144
+ document.title,
145
+ document.description ?? null,
146
+ document.status ?? null,
147
+ document.categories,
148
+ document.brands,
149
+ document.text,
150
+ JSON.stringify(document.payload ?? {}),
151
+ ]);
152
+ }
153
+ return Ok(undefined);
154
+ }
155
+ catch (error) {
156
+ return Err({
157
+ code: "PG_SEARCH_INDEX_FAILED",
158
+ message: error instanceof Error ? error.message : "pg-search indexing failed.",
159
+ });
160
+ }
161
+ },
162
+ async remove(ids) {
163
+ try {
164
+ if (ids.length === 0)
165
+ return Ok(undefined);
166
+ await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
167
+ return Ok(undefined);
168
+ }
169
+ catch (error) {
170
+ return Err({
171
+ code: "PG_SEARCH_DELETE_FAILED",
172
+ message: error instanceof Error ? error.message : "pg-search delete failed.",
173
+ });
174
+ }
175
+ },
176
+ async search(params) {
177
+ try {
178
+ const page = Math.max(1, params.page ?? 1);
179
+ const limit = Math.max(1, Math.min(100, params.limit ?? 20));
180
+ const offset = (page - 1) * limit;
181
+ const where = buildWhere(params, dictionary);
182
+ const scoreExpr = params.query.trim().length > 0
183
+ ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
184
+ : "0";
185
+ const rows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, payload, ${scoreExpr} AS score
186
+ FROM ${table}
187
+ ${where.sql}
188
+ ORDER BY score DESC, title ASC
189
+ LIMIT $${where.values.length + 1}
190
+ OFFSET $${where.values.length + 2}`, [...where.values, limit, offset]);
191
+ const countRows = await options.query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
192
+ const facetRows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, payload
193
+ FROM ${table}
194
+ ${where.sql}`, where.values);
195
+ const documents = facetRows.rows.map((row) => toDocument(row));
196
+ const hits = rows.rows.map((row) => ({
197
+ id: String(row.id ?? ""),
198
+ score: Number(row.score ?? 0),
199
+ document: toDocument(row),
200
+ }));
201
+ return Ok({
202
+ hits,
203
+ total: Number(countRows.rows[0]?.total ?? hits.length),
204
+ page,
205
+ limit,
206
+ facets: computeFacets(documents, params.facets),
207
+ });
208
+ }
209
+ catch (error) {
210
+ return Err({
211
+ code: "PG_SEARCH_QUERY_FAILED",
212
+ message: error instanceof Error ? error.message : "pg-search query failed.",
213
+ });
214
+ }
215
+ },
216
+ async suggest(params) {
217
+ try {
218
+ const limit = Math.max(1, Math.min(25, params.limit ?? 10));
219
+ const prefix = params.prefix.trim().toLowerCase();
220
+ if (!prefix)
221
+ return Ok([]);
222
+ const conditions = ["LOWER(title) LIKE $1"];
223
+ const values = [`${prefix}%`];
224
+ if (params.type) {
225
+ values.push(params.type);
226
+ conditions.push(`type = $${values.length}`);
227
+ }
228
+ values.push(limit);
229
+ const rows = await options.query(`SELECT DISTINCT title
230
+ FROM ${table}
231
+ WHERE ${conditions.join(" AND ")}
232
+ ORDER BY title ASC
233
+ LIMIT $${values.length}`, values);
234
+ const suggestions = rows.rows
235
+ .map((row) => String(row.title ?? ""))
236
+ .filter(Boolean)
237
+ .slice(0, limit);
238
+ return Ok(suggestions);
239
+ }
240
+ catch (error) {
241
+ return Err({
242
+ code: "PG_SEARCH_SUGGEST_FAILED",
243
+ message: error instanceof Error ? error.message : "pg-search suggest failed.",
244
+ });
245
+ }
246
+ },
247
+ };
248
+ }