@porulle/adapter-pg-search 0.11.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
 
@@ -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
@@ -0,0 +1 @@
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"}
@@ -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}`);
@@ -70,8 +98,15 @@ function attributeValues(document, name) {
70
98
  }
71
99
  function toDocument(row) {
72
100
  const attributes = parseAttributes(row.attributes);
101
+ const payload = row.payload && typeof row.payload === "object"
102
+ ? row.payload
103
+ : {};
104
+ const organizationId = typeof payload.organizationId === "string"
105
+ ? payload.organizationId
106
+ : undefined;
73
107
  return {
74
108
  id: String(row.id ?? ""),
109
+ ...(organizationId ? { organizationId } : {}),
75
110
  type: String(row.type ?? ""),
76
111
  slug: String(row.slug ?? ""),
77
112
  title: String(row.title ?? ""),
@@ -81,7 +116,7 @@ function toDocument(row) {
81
116
  brands: parseBrands(row.brands),
82
117
  text: String(row.text ?? ""),
83
118
  ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
84
- ...(row.payload && typeof row.payload === "object" ? { payload: row.payload } : {}),
119
+ ...(Object.keys(payload).length > 0 ? { payload } : {}),
85
120
  };
86
121
  }
87
122
  function buildWhere(params, dictionary) {
@@ -95,6 +130,10 @@ function buildWhere(params, dictionary) {
95
130
  values.push(params.filters.type);
96
131
  clauses.push(`type = $${values.length}`);
97
132
  }
133
+ if (params.filters?.organizationId) {
134
+ values.push(params.filters.organizationId);
135
+ clauses.push(`payload ->> 'organizationId' = $${values.length}`);
136
+ }
98
137
  if (params.filters?.status) {
99
138
  values.push(params.filters.status);
100
139
  clauses.push(`status = $${values.length}`);
@@ -175,17 +214,35 @@ function computeFacets(documents, requested) {
175
214
  }
176
215
  return output;
177
216
  }
178
- export function pgSearchAdapter(options) {
217
+ export function pgSearchAdapter(options = {}) {
179
218
  const table = safeIdentifier(options.tableName ?? "search_index");
180
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
+ }
181
227
  return {
182
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
+ },
183
239
  async index(documents) {
240
+ const query = requireExecute();
184
241
  try {
185
242
  if (documents.length === 0)
186
243
  return Ok(undefined);
187
244
  for (const document of documents) {
188
- 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)
189
246
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
190
247
  ON CONFLICT (id)
191
248
  DO UPDATE SET
@@ -209,7 +266,10 @@ export function pgSearchAdapter(options) {
209
266
  document.brands,
210
267
  document.text,
211
268
  JSON.stringify(document.attributes ?? {}),
212
- JSON.stringify(document.payload ?? {}),
269
+ JSON.stringify({
270
+ ...(document.payload ?? {}),
271
+ ...(document.organizationId ? { organizationId: document.organizationId } : {}),
272
+ }),
213
273
  ]);
214
274
  }
215
275
  return Ok(undefined);
@@ -222,10 +282,11 @@ export function pgSearchAdapter(options) {
222
282
  }
223
283
  },
224
284
  async remove(ids) {
285
+ const query = requireExecute();
225
286
  try {
226
287
  if (ids.length === 0)
227
288
  return Ok(undefined);
228
- await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
289
+ await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
229
290
  return Ok(undefined);
230
291
  }
231
292
  catch (error) {
@@ -236,6 +297,7 @@ export function pgSearchAdapter(options) {
236
297
  }
237
298
  },
238
299
  async search(params) {
300
+ const query = requireExecute();
239
301
  try {
240
302
  const page = Math.max(1, params.page ?? 1);
241
303
  const limit = Math.max(1, Math.min(100, params.limit ?? 20));
@@ -244,14 +306,14 @@ export function pgSearchAdapter(options) {
244
306
  const scoreExpr = params.query.trim().length > 0
245
307
  ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
246
308
  : "0";
247
- 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
248
310
  FROM ${table}
249
311
  ${where.sql}
250
312
  ORDER BY score DESC, title ASC
251
313
  LIMIT $${where.values.length + 1}
252
314
  OFFSET $${where.values.length + 2}`, [...where.values, limit, offset]);
253
- const countRows = await options.query(`SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`, where.values);
254
- 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
255
317
  FROM ${table}
256
318
  ${where.sql}`, where.values);
257
319
  const documents = facetRows.rows.map((row) => toDocument(row));
@@ -276,6 +338,7 @@ export function pgSearchAdapter(options) {
276
338
  }
277
339
  },
278
340
  async suggest(params) {
341
+ const query = requireExecute();
279
342
  try {
280
343
  const limit = Math.max(1, Math.min(25, params.limit ?? 10));
281
344
  const prefix = params.prefix.trim().toLowerCase();
@@ -287,8 +350,12 @@ export function pgSearchAdapter(options) {
287
350
  values.push(params.type);
288
351
  conditions.push(`type = $${values.length}`);
289
352
  }
353
+ if (params.organizationId) {
354
+ values.push(params.organizationId);
355
+ conditions.push(`payload ->> 'organizationId' = $${values.length}`);
356
+ }
290
357
  values.push(limit);
291
- const rows = await options.query(`SELECT DISTINCT title
358
+ const rows = await query(`SELECT DISTINCT title
292
359
  FROM ${table}
293
360
  WHERE ${conditions.join(" AND ")}
294
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.11.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,15 +11,15 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@porulle/core": "0.11.0"
14
+ "@porulle/core": "0.14.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^24.5.2",
18
18
  "eslint": "^9.39.1",
19
19
  "typescript": "5.9.2",
20
20
  "vitest": "^3.2.4",
21
- "@porulle/eslint-config": "0.1.0",
22
- "@porulle/typescript-config": "0.1.0"
21
+ "@porulle/typescript-config": "0.1.0",
22
+ "@porulle/eslint-config": "0.1.0"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
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}`);
@@ -94,8 +140,15 @@ function attributeValues(document: SearchDocument, name: string): string[] {
94
140
 
95
141
  function toDocument(row: PgSearchQueryResultRow): SearchDocument {
96
142
  const attributes = parseAttributes(row.attributes);
143
+ const payload = row.payload && typeof row.payload === "object"
144
+ ? row.payload as Record<string, unknown>
145
+ : {};
146
+ const organizationId = typeof payload.organizationId === "string"
147
+ ? payload.organizationId
148
+ : undefined;
97
149
  return {
98
150
  id: String(row.id ?? ""),
151
+ ...(organizationId ? { organizationId } : {}),
99
152
  type: String(row.type ?? ""),
100
153
  slug: String(row.slug ?? ""),
101
154
  title: String(row.title ?? ""),
@@ -105,7 +158,7 @@ function toDocument(row: PgSearchQueryResultRow): SearchDocument {
105
158
  brands: parseBrands(row.brands),
106
159
  text: String(row.text ?? ""),
107
160
  ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
108
- ...(row.payload && typeof row.payload === "object" ? { payload: row.payload as Record<string, unknown> } : {}),
161
+ ...(Object.keys(payload).length > 0 ? { payload } : {}),
109
162
  };
110
163
  }
111
164
 
@@ -126,6 +179,11 @@ function buildWhere(
126
179
  clauses.push(`type = $${values.length}`);
127
180
  }
128
181
 
182
+ if (params.filters?.organizationId) {
183
+ values.push(params.filters.organizationId);
184
+ clauses.push(`payload ->> 'organizationId' = $${values.length}`);
185
+ }
186
+
129
187
  if (params.filters?.status) {
130
188
  values.push(params.filters.status);
131
189
  clauses.push(`status = $${values.length}`);
@@ -218,19 +276,44 @@ function computeFacets(documents: SearchDocument[], requested?: string[]): Recor
218
276
  return output;
219
277
  }
220
278
 
221
- export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter {
279
+ export function pgSearchAdapter(
280
+ options: PgSearchAdapterOptions = {},
281
+ ): SearchAdapter {
222
282
  const table = safeIdentifier(options.tableName ?? "search_index");
223
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
+ }
224
294
 
225
295
  return {
226
296
  providerId: "pg-search",
227
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
+
228
310
  async index(documents: SearchDocument[]): Promise<Result<void>> {
311
+ const query = requireExecute();
229
312
  try {
230
313
  if (documents.length === 0) return Ok(undefined);
231
314
 
232
315
  for (const document of documents) {
233
- await options.query(
316
+ await query(
234
317
  `INSERT INTO ${table} (id, type, slug, title, description, status, categories, brands, text, attributes, payload)
235
318
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
236
319
  ON CONFLICT (id)
@@ -256,7 +339,10 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
256
339
  document.brands,
257
340
  document.text,
258
341
  JSON.stringify(document.attributes ?? {}),
259
- JSON.stringify(document.payload ?? {}),
342
+ JSON.stringify({
343
+ ...(document.payload ?? {}),
344
+ ...(document.organizationId ? { organizationId: document.organizationId } : {}),
345
+ }),
260
346
  ],
261
347
  );
262
348
  }
@@ -271,9 +357,10 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
271
357
  },
272
358
 
273
359
  async remove(ids: string[]): Promise<Result<void>> {
360
+ const query = requireExecute();
274
361
  try {
275
362
  if (ids.length === 0) return Ok(undefined);
276
- await options.query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
363
+ await query(`DELETE FROM ${table} WHERE id = ANY($1::text[])`, [ids]);
277
364
  return Ok(undefined);
278
365
  } catch (error) {
279
366
  return Err({
@@ -284,6 +371,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
284
371
  },
285
372
 
286
373
  async search(params: SearchQueryParams): Promise<Result<SearchQueryResult>> {
374
+ const query = requireExecute();
287
375
  try {
288
376
  const page = Math.max(1, params.page ?? 1);
289
377
  const limit = Math.max(1, Math.min(100, params.limit ?? 20));
@@ -294,7 +382,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
294
382
  ? `ts_rank(to_tsvector('${dictionary}', text), plainto_tsquery('${dictionary}', $1))`
295
383
  : "0";
296
384
 
297
- const rows = await options.query(
385
+ const rows = await query(
298
386
  `SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload, ${scoreExpr} AS score
299
387
  FROM ${table}
300
388
  ${where.sql}
@@ -304,12 +392,12 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
304
392
  [...where.values, limit, offset],
305
393
  );
306
394
 
307
- const countRows = await options.query(
395
+ const countRows = await query(
308
396
  `SELECT COUNT(*)::int AS total FROM ${table} ${where.sql}`,
309
397
  where.values,
310
398
  );
311
399
 
312
- const facetRows = await options.query(
400
+ const facetRows = await query(
313
401
  `SELECT id, type, slug, title, description, status, categories, brands, text, attributes, payload
314
402
  FROM ${table}
315
403
  ${where.sql}`,
@@ -339,6 +427,7 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
339
427
  },
340
428
 
341
429
  async suggest(params: SearchSuggestParams): Promise<Result<string[]>> {
430
+ const query = requireExecute();
342
431
  try {
343
432
  const limit = Math.max(1, Math.min(25, params.limit ?? 10));
344
433
  const prefix = params.prefix.trim().toLowerCase();
@@ -353,9 +442,14 @@ export function pgSearchAdapter(options: PgSearchAdapterOptions): SearchAdapter
353
442
  conditions.push(`type = $${values.length}`);
354
443
  }
355
444
 
445
+ if (params.organizationId) {
446
+ values.push(params.organizationId);
447
+ conditions.push(`payload ->> 'organizationId' = $${values.length}`);
448
+ }
449
+
356
450
  values.push(limit);
357
451
 
358
- const rows = await options.query(
452
+ const rows = await query(
359
453
  `SELECT DISTINCT title
360
454
  FROM ${table}
361
455
  WHERE ${conditions.join(" AND ")}
@@ -1 +0,0 @@
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;AAyMD,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,aAAa,CAgK9E"}