@porulle/adapter-pg-search 0.13.0 → 0.14.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/README.md CHANGED
@@ -11,7 +11,6 @@ import { pgSearchAdapter } from "@porulle/adapter-pg-search";
11
11
  export default defineConfig({
12
12
  search: {
13
13
  adapter: pgSearchAdapter({
14
- query: (sql, params) => yourPgClient.query(sql, params),
15
14
  tableName: "search_index", // default
16
15
  dictionary: "english", // default; pick "simple" for non-English heavy stores
17
16
  }),
@@ -20,7 +19,7 @@ export default defineConfig({
20
19
  });
21
20
  ```
22
21
 
23
- The `query` function should be a thin wrapper around your PG client (`postgres`, `pg`, etc.) that returns `{ rows: ... }`. The adapter sends parameterized queries never string-concatenates user input.
22
+ With no options, the adapter uses the configured `databaseAdapter` after the search module starts. To use a separate search database, pass a `query` function as a thin wrapper around your PG client (`postgres`, `pg`, etc.) that returns `{ rows: ... }`; the adapter sends parameterized queries and preserves that callback when the module initializes.
24
23
 
25
24
  ## When to pick this over Meilisearch
26
25
 
package/dist/index.d.ts CHANGED
@@ -3,11 +3,11 @@ export interface PgSearchQueryResultRow {
3
3
  [key: string]: unknown;
4
4
  }
5
5
  export interface PgSearchAdapterOptions {
6
- query: (sql: string, params: unknown[]) => Promise<{
6
+ query?: (sql: string, params: unknown[]) => Promise<{
7
7
  rows: PgSearchQueryResultRow[];
8
8
  }>;
9
9
  tableName?: string;
10
10
  dictionary?: string;
11
11
  }
12
- export declare function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter;
12
+ export declare function pgSearchAdapter(options?: PgSearchAdapterOptions): SearchAdapter;
13
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAKnB,MAAM,eAAe,CAAC;AAEvB,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;AAqND,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,aAAa,CAwK9E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,aAAa,EAMnB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,sBAAsB;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,CACN,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,OAAO,EAAE,KACd,OAAO,CAAC;QAAE,IAAI,EAAE,sBAAsB,EAAE,CAAA;KAAE,CAAC,CAAC;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA8PD,wBAAgB,eAAe,CAC7B,OAAO,GAAE,sBAA2B,GACnC,aAAa,CAkMf"}
package/dist/index.js CHANGED
@@ -1,4 +1,32 @@
1
1
  import { Err, Ok, } from "@porulle/core";
2
+ import { sql } from "@porulle/core/drizzle";
3
+ function databaseRows(result) {
4
+ if (Array.isArray(result))
5
+ return result;
6
+ if (result && typeof result === "object") {
7
+ const rows = result.rows;
8
+ if (Array.isArray(rows))
9
+ return rows;
10
+ }
11
+ throw new Error("Configured database execute() did not return rows.");
12
+ }
13
+ function toDatabaseQuery(query, params) {
14
+ const chunks = [];
15
+ const placeholders = /\$(\d+)/g;
16
+ let cursor = 0;
17
+ let match;
18
+ while ((match = placeholders.exec(query)) !== null) {
19
+ const parameterIndex = Number(match[1]) - 1;
20
+ if (parameterIndex < 0 || parameterIndex >= params.length) {
21
+ throw new Error(`Query placeholder $${match[1]} has no matching parameter.`);
22
+ }
23
+ chunks.push(sql.raw(query.slice(cursor, match.index)));
24
+ chunks.push(sql.param(params[parameterIndex]));
25
+ cursor = placeholders.lastIndex;
26
+ }
27
+ chunks.push(sql.raw(query.slice(cursor)));
28
+ return sql.join(chunks, sql.empty());
29
+ }
2
30
  function safeIdentifier(value) {
3
31
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
4
32
  throw new Error(`Invalid SQL identifier: ${value}`);
@@ -186,17 +214,35 @@ function computeFacets(documents, requested) {
186
214
  }
187
215
  return output;
188
216
  }
189
- export function pgSearchAdapter(options) {
217
+ export function pgSearchAdapter(options = {}) {
190
218
  const table = safeIdentifier(options.tableName ?? "search_index");
191
219
  const dictionary = options.dictionary ?? "english";
220
+ let execute = options.query;
221
+ function requireExecute() {
222
+ if (!execute) {
223
+ throw new Error("pgSearchAdapter has no way to reach the database: no `query` was supplied and init() has not run. Pass the adapter through config.search.adapter so the search module can wire it.");
224
+ }
225
+ return execute;
226
+ }
192
227
  return {
193
228
  providerId: "pg-search",
229
+ init({ db }) {
230
+ if (execute)
231
+ return;
232
+ const database = db;
233
+ execute = async (query, params) => ({
234
+ rows: databaseRows(await (database.dialect === undefined
235
+ ? database.execute(query, params)
236
+ : database.execute(toDatabaseQuery(query, params)))),
237
+ });
238
+ },
194
239
  async index(documents) {
240
+ const query = requireExecute();
195
241
  try {
196
242
  if (documents.length === 0)
197
243
  return Ok(undefined);
198
244
  for (const document of documents) {
199
- await options.query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
245
+ await query(`INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
200
246
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
201
247
  ON CONFLICT (id)
202
248
  DO UPDATE SET
@@ -236,10 +282,11 @@ export function pgSearchAdapter(options) {
236
282
  }
237
283
  },
238
284
  async remove(ids) {
285
+ const query = requireExecute();
239
286
  try {
240
287
  if (ids.length === 0)
241
288
  return Ok(undefined);
242
- await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
289
+ await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
243
290
  return Ok(undefined);
244
291
  }
245
292
  catch (error) {
@@ -250,6 +297,7 @@ export function pgSearchAdapter(options) {
250
297
  }
251
298
  },
252
299
  async search(params) {
300
+ const query = requireExecute();
253
301
  try {
254
302
  const page = Math.max(1, params.page ?? 1);
255
303
  const limit = Math.max(1, Math.min(100, params.limit ?? 20));
@@ -258,14 +306,14 @@ export function pgSearchAdapter(options) {
258
306
  const scoreExpr = params.query.trim().length > 0
259
307
  ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
260
308
  : "0";
261
- const rows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
309
+ const rows = await query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
262
310
  FROM ${table}
263
311
  ${where.sql}
264
312
  ORDER BY score DESC, title ASC
265
313
  LIMIT $${where.values.length + 1}
266
314
  OFFSET $${where.values.length + 2}`, [...where.values, limit, offset]);
267
- const countRows = await options.query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
268
- const facetRows = await options.query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
315
+ const countRows = await query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
316
+ const facetRows = await query(`SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
269
317
  FROM ${table}
270
318
  ${where.sql}`, where.values);
271
319
  const documents = facetRows.rows.map((row) => toDocument(row));
@@ -290,6 +338,7 @@ export function pgSearchAdapter(options) {
290
338
  }
291
339
  },
292
340
  async suggest(params) {
341
+ const query = requireExecute();
293
342
  try {
294
343
  const limit = Math.max(1, Math.min(25, params.limit ?? 10));
295
344
  const prefix = params.prefix.trim().toLowerCase();
@@ -306,7 +355,7 @@ export function pgSearchAdapter(options) {
306
355
  conditions.push(`payload ->> 'organizationId' = $${values.length}`);
307
356
  }
308
357
  values.push(limit);
309
- const rows = await options.query(`SELECT DISTINCT title
358
+ const rows = await query(`SELECT DISTINCT title
310
359
  FROM ${table}
311
360
  WHERE ${conditions.join(" AND ")}
312
361
  ORDER BY title ASC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-pg-search",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@porulle/core": "0.13.0"
14
+ "@porulle/core": "0.14.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -7,18 +7,64 @@ import {
7
7
  type SearchQueryParams,
8
8
  type SearchQueryResult,
9
9
  type SearchSuggestParams,
10
+ type PluginDb,
10
11
  } from "@porulle/core";
12
+ import { sql, type SQL } from "@porulle/core/drizzle";
11
13
 
12
14
  export interface PgSearchQueryResultRow {
13
15
  [key: string]: unknown;
14
16
  }
15
17
 
16
18
  export interface PgSearchAdapterOptions {
17
- query: (sql: string, params: unknown[]) => Promise<{ rows: PgSearchQueryResultRow[] }>;
19
+ query?: (
20
+ sql: string,
21
+ params: unknown[],
22
+ ) => Promise<{ rows: PgSearchQueryResultRow[] }>;
18
23
  tableName?: string;
19
24
  dictionary?: string;
20
25
  }
21
26
 
27
+ type PgSearchQuery = (
28
+ sql: string,
29
+ params: unknown[],
30
+ ) => Promise<{ rows: PgSearchQueryResultRow[] }>;
31
+
32
+ type PgSearchDatabase = {
33
+ execute(query: SQL | string, params?: unknown[]): Promise<unknown>;
34
+ dialect?: unknown;
35
+ };
36
+
37
+ function databaseRows(result: unknown): PgSearchQueryResultRow[] {
38
+ if (Array.isArray(result)) return result as PgSearchQueryResultRow[];
39
+ if (result && typeof result === "object") {
40
+ const rows = (result as { rows?: unknown }).rows;
41
+ if (Array.isArray(rows)) return rows as PgSearchQueryResultRow[];
42
+ }
43
+ throw new Error("Configured database execute() did not return rows.");
44
+ }
45
+
46
+ function toDatabaseQuery(query: string, params: unknown[]): SQL {
47
+ const chunks = [];
48
+ const placeholders = /\$(\d+)/g;
49
+ let cursor = 0;
50
+ let match: RegExpExecArray | null;
51
+
52
+ while ((match = placeholders.exec(query)) !== null) {
53
+ const parameterIndex = Number(match[1]) - 1;
54
+ if (parameterIndex < 0 || parameterIndex >= params.length) {
55
+ throw new Error(
56
+ `Query placeholder $${match[1]} has no matching parameter.`,
57
+ );
58
+ }
59
+ chunks.push(sql.raw(query.slice(cursor, match.index)));
60
+ chunks.push(sql.param(params[parameterIndex]));
61
+ cursor = placeholders.lastIndex;
62
+ }
63
+
64
+ chunks.push(sql.raw(query.slice(cursor)));
65
+ return sql.join(chunks, sql.empty());
66
+ }
67
+
22
68
  function safeIdentifier(value: string): string {
23
69
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
24
70
  throw new Error(`Invalid SQL identifier: ${value}`);
@@ -96,10 +142,10 @@ function toDocument(row: PgSearchQueryResultRow): SearchDocument {
96
142
  const attributes = parseAttributes(row.attributes);
97
143
  const payload = row.payload && typeof row.payload === "object"
98
144
  ? row.payload as Record<string, unknown>
99
- : {};
145
+ : {};
100
146
  const organizationId = typeof payload.organizationId === "string"
101
- ? payload.organizationId
102
- : undefined;
147
+ ? payload.organizationId
148
+ : undefined;
103
149
  return {
104
150
  id: String(row.id ?? ""),
105
151
  ...(organizationId ? { organizationId } : {}),
@@ -230,19 +276,44 @@ function computeFacets(documents: SearchDocument[], requested?: string[]): Recor
230
276
  return output;
231
277
  }
232
278
 
233
- export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter {
279
+ export function pgSearchAdapter(
280
+ options: PgSearchAdapterOptions = {},
281
+ ): SearchAdapter {
234
282
  const table = safeIdentifier(options.tableName ?? "search_index");
235
283
  const dictionary = options.dictionary ?? "english";
284
+ let execute: PgSearchQuery | undefined = options.query;
285
+
286
+ function requireExecute(): PgSearchQuery {
287
+ if (!execute) {
288
+ throw new Error(
289
+ "pgSearchAdapter has no way to reach the database: no `query` was supplied and init() has not run. Pass the adapter through config.search.adapter so the search module can wire it.",
290
+ );
291
+ }
292
+ return execute;
293
+ }
236
294
 
237
295
  return {
238
296
  providerId: "pg-search",
239
297
 
298
+ init({ db }: { db: PluginDb }): void {
299
+ if (execute) return;
300
+ const database = db as unknown as PgSearchDatabase;
301
+ execute = async (query, params) => ({
302
+ rows: databaseRows(
303
+ await (database.dialect === undefined
304
+ ? database.execute(query, params)
305
+ : database.execute(toDatabaseQuery(query, params))),
306
+ ),
307
+ });
308
+ },
309
+
240
310
  async index(documents: SearchDocument[]): Promise<Result<void>> {
311
+ const query = requireExecute();
241
312
  try {
242
313
  if (documents.length === 0) return Ok(undefined);
243
314
 
244
315
  for (const document of documents) {
245
- await options.query(
316
+ await query(
246
317
  `INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
247
318
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
248
319
  ON CONFLICT (id)
@@ -286,9 +357,10 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
286
357
  },
287
358
 
288
359
  async remove(ids: string[]): Promise<Result<void>> {
360
+ const query = requireExecute();
289
361
  try {
290
362
  if (ids.length === 0) return Ok(undefined);
291
- await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
363
+ await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
292
364
  return Ok(undefined);
293
365
  } catch (error) {
294
366
  return Err({
@@ -299,6 +371,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
299
371
  },
300
372
 
301
373
  async search(params: SearchQueryParams): Promise<Result<SearchQueryResult>> {
374
+ const query = requireExecute();
302
375
  try {
303
376
  const page = Math.max(1, params.page ?? 1);
304
377
  const limit = Math.max(1, Math.min(100, params.limit ?? 20));
@@ -309,7 +382,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
309
382
  ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
310
383
  : "0";
311
384
 
312
- const rows = await options.query(
385
+ const rows = await query(
313
386
  `SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
314
387
  FROM ${table}
315
388
  ${where.sql}
@@ -319,12 +392,12 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
319
392
  [...where.values, limit, offset],
320
393
  );
321
394
 
322
- const countRows = await options.query(
395
+ const countRows = await query(
323
396
  `SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`,
324
397
  where.values,
325
398
  );
326
399
 
327
- const facetRows = await options.query(
400
+ const facetRows = await query(
328
401
  `SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
329
402
  FROM ${table}
330
403
  ${where.sql}`,
@@ -354,6 +427,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
354
427
  },
355
428
 
356
429
  async suggest(params: SearchSuggestParams): Promise<Result<string[]>> {
430
+ const query = requireExecute();
357
431
  try {
358
432
  const limit = Math.max(1, Math.min(25, params.limit ?? 10));
359
433
  const prefix = params.prefix.trim().toLowerCase();
@@ -375,7 +449,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
375
449
 
376
450
  values.push(limit);
377
451
 
378
- const rows = await options.query(
452
+ const rows = await query(
379
453
  `SELECT DISTINCT title
380
454
  FROM ${table}
381
455
  WHERE ${conditions.join(" AND ")}