@murumets-ee/search-postgres 0.12.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/LICENSE +94 -0
- package/dist/index.d.mts +129 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Elastic License 2.0 (ELv2)
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject
|
|
14
|
+
to the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set
|
|
20
|
+
of the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other
|
|
27
|
+
notices of the licensor in the software. Any use of the licensor's trademarks
|
|
28
|
+
is subject to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license
|
|
39
|
+
for the software granted under these terms ends immediately. If your company
|
|
40
|
+
makes such a claim, your patent license ends immediately for work on behalf
|
|
41
|
+
of your company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
46
|
+
you also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license
|
|
61
|
+
no later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your
|
|
64
|
+
licenses to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is
|
|
76
|
+
the software the licensor makes available under these terms, including any
|
|
77
|
+
portion of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that organization.
|
|
84
|
+
**control** means ownership of substantially all the assets of an entity, or
|
|
85
|
+
the power to direct the management and policies of an entity (for example, by
|
|
86
|
+
voting right, contract, or otherwise). Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your
|
|
92
|
+
licenses.
|
|
93
|
+
|
|
94
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { SearchCapabilities, SearchInput, SearchProvider, SearchResult, SearchResultRow } from "@murumets-ee/search";
|
|
2
|
+
import { CountOptions, FindManyOptions } from "@murumets-ee/entity/admin";
|
|
3
|
+
|
|
4
|
+
//#region src/ilike-provider.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Minimal `AdminClient` shape consumed by `IlikeProvider`. Avoids the heavy
|
|
7
|
+
* generic plumbing of the full `AdminClient<F>` type while keeping the only
|
|
8
|
+
* three methods we use type-checked.
|
|
9
|
+
*
|
|
10
|
+
* `getTable()` returns the Drizzle table with dynamically-typed columns —
|
|
11
|
+
* matches `AdminClient.getTable()` exactly, so the cast at the call site is
|
|
12
|
+
* one-way (entity → here, never back).
|
|
13
|
+
*/
|
|
14
|
+
interface AdminClientLike<TRow> {
|
|
15
|
+
findMany(options: FindManyOptions): Promise<TRow[]>;
|
|
16
|
+
count(options: CountOptions): Promise<number>;
|
|
17
|
+
getTable(): PgTableLike;
|
|
18
|
+
}
|
|
19
|
+
type PgTableLike = {
|
|
20
|
+
readonly [column: string]: any;
|
|
21
|
+
};
|
|
22
|
+
interface IlikeProviderConfig<TRow> {
|
|
23
|
+
/** Logical resource name (registry key, route segment, audit tag). */
|
|
24
|
+
resource: string;
|
|
25
|
+
/**
|
|
26
|
+
* Permission resource for the framework `view` check. Defaults to
|
|
27
|
+
* `resource`. Override when the search resource and the underlying
|
|
28
|
+
* entity permission diverge.
|
|
29
|
+
*/
|
|
30
|
+
permissionResource?: string;
|
|
31
|
+
/** Underlying entity client. */
|
|
32
|
+
client: AdminClientLike<TRow>;
|
|
33
|
+
/**
|
|
34
|
+
* Whitelist of fields to ILIKE-match against the user's query. Each entry
|
|
35
|
+
* MUST name a real column on the entity's Drizzle table; the constructor
|
|
36
|
+
* throws on a typo.
|
|
37
|
+
*/
|
|
38
|
+
searchableFields: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Whitelist of fields callers may filter on via `SearchInput.filters`
|
|
41
|
+
* (e.g. `?f.status=open`). Defaults to `searchableFields` when omitted.
|
|
42
|
+
*
|
|
43
|
+
* This list is the single line of defense against the "arbitrary
|
|
44
|
+
* user-supplied field name" injection vector documented on `FacetFilters`.
|
|
45
|
+
* Field names outside this set are silently dropped. Each entry MUST name
|
|
46
|
+
* a real column on the entity's Drizzle table; the constructor throws on
|
|
47
|
+
* a typo.
|
|
48
|
+
*
|
|
49
|
+
* Use this when filterable and searchable concerns diverge — e.g. you
|
|
50
|
+
* want users to text-search `name`/`sku` but also constrain by `status`
|
|
51
|
+
* without making `status` a substring-search target.
|
|
52
|
+
*/
|
|
53
|
+
filterableFields?: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* Project an entity row into a `SearchResultRow`. Required by D14 — no
|
|
56
|
+
* default projection because the right `label` / `description` / `url`
|
|
57
|
+
* are entity-specific and a generic guess silently leaks the wrong field.
|
|
58
|
+
*/
|
|
59
|
+
transform: (row: TRow) => SearchResultRow;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `ILIKE` provider over an `AdminClient`. Suits low-cardinality admin
|
|
63
|
+
* lookups (orders, customers) where Postgres B-tree + `pg_trgm` (optional)
|
|
64
|
+
* is enough and the operator cost of an ES cluster isn't justified.
|
|
65
|
+
*
|
|
66
|
+
* Modes:
|
|
67
|
+
* - `term` — exact equality across `searchableFields`, OR-combined.
|
|
68
|
+
* - `prefix` — `ILIKE 'q%'` across `searchableFields`, OR-combined. User
|
|
69
|
+
* input has LIKE wildcards escaped via {@link escapeLikePattern}.
|
|
70
|
+
* - `phrase` — `ILIKE '%q%'` across `searchableFields`, OR-combined. Same
|
|
71
|
+
* wildcard escaping. There is no scoring; results are returned
|
|
72
|
+
* in the entity's default order (id ascending).
|
|
73
|
+
*
|
|
74
|
+
* Filters: caller-supplied `filters` map is intersected with
|
|
75
|
+
* `searchableFields`. Unknown fields are dropped; values are compared with
|
|
76
|
+
* `eq` (single value) or `inArray` (multiple). No interpolation reaches SQL —
|
|
77
|
+
* Drizzle parameterizes everything.
|
|
78
|
+
*/
|
|
79
|
+
declare class IlikeProvider<TRow> implements SearchProvider {
|
|
80
|
+
readonly resource: string;
|
|
81
|
+
readonly permissionResource?: string;
|
|
82
|
+
readonly capabilities: SearchCapabilities;
|
|
83
|
+
private readonly client;
|
|
84
|
+
private readonly searchableFields;
|
|
85
|
+
private readonly filterableFieldSet;
|
|
86
|
+
private readonly transform;
|
|
87
|
+
constructor(config: IlikeProviderConfig<TRow>);
|
|
88
|
+
search(input: SearchInput, signal: AbortSignal): Promise<SearchResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Build the OR-combined query condition across `searchableFields`. Returns
|
|
91
|
+
* `null` (not `undefined`) when no searchable column resolves — caller
|
|
92
|
+
* shorts to an empty result rather than executing an unbounded query.
|
|
93
|
+
*/
|
|
94
|
+
private buildQueryCondition;
|
|
95
|
+
/**
|
|
96
|
+
* Compile `filters` into AND-combined `eq` / `inArray` conditions.
|
|
97
|
+
*
|
|
98
|
+
* Field names are checked against `searchableFieldSet` — anything outside
|
|
99
|
+
* is silently dropped. The mechanics layer already caps the number and
|
|
100
|
+
* size of filters; this is the type-and-name validation step.
|
|
101
|
+
*/
|
|
102
|
+
private buildFilterConditions;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/tsvector-provider.d.ts
|
|
106
|
+
/**
|
|
107
|
+
* Placeholder for a Postgres `tsvector` / `tsquery` provider.
|
|
108
|
+
*
|
|
109
|
+
* Reserved name + interface so that consumers and the registry already know
|
|
110
|
+
* the shape; implementation lands when a first consumer needs ranking /
|
|
111
|
+
* stemming on the Postgres tier (per PLAN-ECOMMERCE.md §6 "Beyond PR 9").
|
|
112
|
+
*
|
|
113
|
+
* Until then, calling `search()` throws — this is preferable to silently
|
|
114
|
+
* falling back to ILIKE, which would mask the missing capability.
|
|
115
|
+
*/
|
|
116
|
+
interface TsvectorProviderConfig {
|
|
117
|
+
resource: string;
|
|
118
|
+
permissionResource?: string;
|
|
119
|
+
}
|
|
120
|
+
declare class TsvectorProvider implements SearchProvider {
|
|
121
|
+
readonly resource: string;
|
|
122
|
+
readonly permissionResource?: string;
|
|
123
|
+
readonly capabilities: SearchCapabilities;
|
|
124
|
+
constructor(config: TsvectorProviderConfig);
|
|
125
|
+
search(_input: SearchInput, _signal: AbortSignal): Promise<SearchResult>;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
export { IlikeProvider, type IlikeProviderConfig, TsvectorProvider, type TsvectorProviderConfig };
|
|
129
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/ilike-provider.ts","../src/tsvector-provider.ts"],"mappings":";;;;;;AAqBA;;;;;;;UAAiB,eAAA;EACf,QAAA,CAAS,OAAA,EAAS,eAAA,GAAkB,OAAA,CAAQ,IAAA;EAC5C,KAAA,CAAM,OAAA,EAAS,YAAA,GAAe,OAAA;EAE9B,QAAA,IAAY,WAAA;AAAA;AAAA,KAIT,WAAA;EAAA,UAA0B,MAAA;AAAA;AAAA,UAOd,mBAAA;EAd6B;EAgB5C,QAAA;EAfe;;;;;EAqBf,kBAAA;EAnBuB;EAqBvB,MAAA,EAAQ,eAAA,CAAgB,IAAA;EAjBV;;;;AAOhB;EAgBE,gBAAA;EAhBkC;;;;;;;;;;;;;;EA+BlC,gBAAA;EAMA;;;;;EAAA,SAAA,GAAY,GAAA,EAAK,IAAA,KAAS,eAAA;AAAA;;;;;;;;;;;;;;;;;;;cA6Bf,aAAA,kBAA+B,cAAA;EAAA,SACjC,QAAA;EAAA,SACA,kBAAA;EAAA,SACA,YAAA,EAAY,kBAAA;EAAA,iBAEJ,MAAA;EAAA,iBACA,gBAAA;EAAA,iBACA,kBAAA;EAAA,iBACA,SAAA;cAEL,MAAA,EAAQ,mBAAA,CAAoB,IAAA;EA8BlC,MAAA,CAAO,KAAA,EAAO,WAAA,EAAa,MAAA,EAAQ,WAAA,GAAc,OAAA,CAAQ,YAAA;EAAlD;;;;;EAAA,QAoDL,mBAAA;EAgCA;;;;;;AChNV;EDgNU,QAAA,qBAAA;AAAA;;;;;;AA7MV;;;;;;;UCHiB,sBAAA;EACf,QAAA;EACA,kBAAA;AAAA;AAAA,cAWW,gBAAA,YAA4B,cAAA;EAAA,SAC9B,QAAA;EAAA,SACA,kBAAA;EAAA,SACA,YAAA,EAAY,kBAAA;cAET,MAAA,EAAQ,sBAAA;EAOpB,MAAA,CAAO,MAAA,EAAQ,WAAA,EAAa,OAAA,EAAS,WAAA,GAAc,OAAA,CAAQ,YAAA;AAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{escapeLikePattern as e}from"@murumets-ee/core";import{SearchError as t,SearchErrorCodes as n}from"@murumets-ee/search";import{and as r,eq as i,ilike as a,inArray as o,or as s}from"drizzle-orm";function c(e,t){return e[t]}const l={fullText:!1,ranking:!1,facets:!1,fuzzy:!1,prefix:!0};var u=class{resource;permissionResource;capabilities=l;client;searchableFields;filterableFieldSet;transform;constructor(e){if(e.searchableFields.length===0)throw Error(`IlikeProvider '${e.resource}' requires at least one searchable field`);let t=e.client.getTable(),n=e.filterableFields??e.searchableFields;for(let r of[...e.searchableFields,...n])if(c(t,r)===void 0)throw Error(`IlikeProvider '${e.resource}' field '${r}' is not a column on the entity table`);this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.client=e.client,this.searchableFields=e.searchableFields,this.filterableFieldSet=new Set(n),this.transform=e.transform}async search(e,i){if(i.aborted)throw new t(`Search aborted`,504,n.Timeout);let a=this.buildQueryCondition(e);if(a===null)return{rows:[],total:0,facets:[],durationMs:0};let o=r(a,...this.buildFilterConditions(e.filters));if(o===void 0)throw Error(`unreachable: and() called with at least one arg`);let s=o,c={where:s,limit:e.limit,offset:e.offset};e.locale!==void 0&&(c.locale=e.locale);let l=Date.now(),u=await this.client.findMany(c);if(i.aborted)throw new t(`Search aborted`,504,n.Timeout);let d=Date.now()-l,f=await this.client.count({where:s});if(i.aborted)throw new t(`Search aborted`,504,n.Timeout);return{rows:u.map(e=>this.transform(e)),total:f,facets:[],durationMs:d}}buildQueryCondition(e){let t=this.client.getTable(),n=[],r=d(e.query,e.mode);for(let o of this.searchableFields){let s=c(t,o);s!==void 0&&(e.mode===`term`?n.push(i(s,e.query)):n.push(a(s,r)))}if(n.length===0)return null;let o=n[0];if(o===void 0)return null;if(n.length===1)return o;let l=s(...n);if(l===void 0)throw Error(`unreachable: or() called with ≥ 2 args`);return l}buildFilterConditions(e){let t=this.client.getTable(),n=[];for(let[r,a]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=c(t,r);if(e!==void 0&&a.length!==0)if(a.length===1){let t=a[0];if(t===void 0)continue;n.push(i(e,t))}else n.push(o(e,[...a]))}return n}};function d(t,n){let r=e(t);return n===`prefix`?`${r}%`:`%${r}%`}const f={fullText:!0,ranking:!0,facets:!1,fuzzy:!1,prefix:!0};var p=class{resource;permissionResource;capabilities=f;constructor(e){this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource)}search(e,n){return Promise.reject(new t(`TsvectorProvider '${this.resource}' is not implemented yet. Use IlikeProvider for substring search or wait for the Postgres FTS implementation.`,501,`not_implemented`))}};export{u as IlikeProvider,p as TsvectorProvider};
|
|
2
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/ilike-provider.ts","../src/tsvector-provider.ts"],"sourcesContent":["import { escapeLikePattern } from '@murumets-ee/core'\nimport type { CountOptions, FindManyOptions } from '@murumets-ee/entity/admin'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport type {\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport { and, type Column, eq, ilike, inArray, or, type SQL } from 'drizzle-orm'\n\n/**\n * Minimal `AdminClient` shape consumed by `IlikeProvider`. Avoids the heavy\n * generic plumbing of the full `AdminClient<F>` type while keeping the only\n * three methods we use type-checked.\n *\n * `getTable()` returns the Drizzle table with dynamically-typed columns —\n * matches `AdminClient.getTable()` exactly, so the cast at the call site is\n * one-way (entity → here, never back).\n */\nexport interface AdminClientLike<TRow> {\n findMany(options: FindManyOptions): Promise<TRow[]>\n count(options: CountOptions): Promise<number>\n // biome-ignore lint/suspicious/noExplicitAny: entity tables have dynamic columns; matches AdminClient.getTable() shape\n getTable(): PgTableLike\n}\n\n// biome-ignore lint/suspicious/noExplicitAny: Drizzle column shapes are dynamic\ntype PgTableLike = { readonly [column: string]: any }\n\n/** Narrow `table[field]` from `any` to `Column | undefined` at every read. */\nfunction resolveColumn(table: PgTableLike, field: string): Column | undefined {\n return table[field] as Column | undefined\n}\n\nexport interface IlikeProviderConfig<TRow> {\n /** Logical resource name (registry key, route segment, audit tag). */\n resource: string\n /**\n * Permission resource for the framework `view` check. Defaults to\n * `resource`. Override when the search resource and the underlying\n * entity permission diverge.\n */\n permissionResource?: string\n /** Underlying entity client. */\n client: AdminClientLike<TRow>\n /**\n * Whitelist of fields to ILIKE-match against the user's query. Each entry\n * MUST name a real column on the entity's Drizzle table; the constructor\n * throws on a typo.\n */\n searchableFields: readonly string[]\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`\n * (e.g. `?f.status=open`). Defaults to `searchableFields` when omitted.\n *\n * This list is the single line of defense against the \"arbitrary\n * user-supplied field name\" injection vector documented on `FacetFilters`.\n * Field names outside this set are silently dropped. Each entry MUST name\n * a real column on the entity's Drizzle table; the constructor throws on\n * a typo.\n *\n * Use this when filterable and searchable concerns diverge — e.g. you\n * want users to text-search `name`/`sku` but also constrain by `status`\n * without making `status` a substring-search target.\n */\n filterableFields?: readonly string[]\n /**\n * Project an entity row into a `SearchResultRow`. Required by D14 — no\n * default projection because the right `label` / `description` / `url`\n * are entity-specific and a generic guess silently leaks the wrong field.\n */\n transform: (row: TRow) => SearchResultRow\n}\n\nconst ILIKE_CAPABILITIES: SearchCapabilities = {\n fullText: false,\n ranking: false,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\n/**\n * `ILIKE` provider over an `AdminClient`. Suits low-cardinality admin\n * lookups (orders, customers) where Postgres B-tree + `pg_trgm` (optional)\n * is enough and the operator cost of an ES cluster isn't justified.\n *\n * Modes:\n * - `term` — exact equality across `searchableFields`, OR-combined.\n * - `prefix` — `ILIKE 'q%'` across `searchableFields`, OR-combined. User\n * input has LIKE wildcards escaped via {@link escapeLikePattern}.\n * - `phrase` — `ILIKE '%q%'` across `searchableFields`, OR-combined. Same\n * wildcard escaping. There is no scoring; results are returned\n * in the entity's default order (id ascending).\n *\n * Filters: caller-supplied `filters` map is intersected with\n * `searchableFields`. Unknown fields are dropped; values are compared with\n * `eq` (single value) or `inArray` (multiple). No interpolation reaches SQL —\n * Drizzle parameterizes everything.\n */\nexport class IlikeProvider<TRow> implements SearchProvider {\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities = ILIKE_CAPABILITIES\n\n private readonly client: AdminClientLike<TRow>\n private readonly searchableFields: readonly string[]\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly transform: (row: TRow) => SearchResultRow\n\n constructor(config: IlikeProviderConfig<TRow>) {\n if (config.searchableFields.length === 0) {\n throw new Error(\n `IlikeProvider '${config.resource}' requires at least one searchable field`,\n )\n }\n\n // Fail-fast: every configured field must resolve to a real column on the\n // entity's Drizzle table. A typo here is the difference between \"search\n // returns nothing\" and \"search inadvertently widens the WHERE clause\".\n const table = config.client.getTable()\n const filterableFields = config.filterableFields ?? config.searchableFields\n for (const field of [...config.searchableFields, ...filterableFields]) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `IlikeProvider '${config.resource}' field '${field}' is not a column on the entity table`,\n )\n }\n }\n\n this.resource = config.resource\n if (config.permissionResource !== undefined) {\n this.permissionResource = config.permissionResource\n }\n this.client = config.client\n this.searchableFields = config.searchableFields\n this.filterableFieldSet = new Set(filterableFields)\n this.transform = config.transform\n }\n\n async search(input: SearchInput, signal: AbortSignal): Promise<SearchResult> {\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n\n const queryCondition = this.buildQueryCondition(input)\n // No matchable fields → empty result. Bailing here is safer than skipping\n // the WHERE and returning every row that matches the filter constraints.\n if (queryCondition === null) {\n return { rows: [], total: 0, facets: [], durationMs: 0 }\n }\n\n const filterConditions = this.buildFilterConditions(input.filters)\n // `and()` is typed `SQL | undefined`, but it only returns undefined when\n // called with zero args; we always pass `queryCondition` plus zero-or-more\n // filter conditions, so the result is non-undefined by construction.\n const combined = and(queryCondition, ...filterConditions)\n if (combined === undefined) throw new Error('unreachable: and() called with at least one arg')\n const where: SQL = combined\n\n const findOptions: FindManyOptions = {\n where,\n limit: input.limit,\n offset: input.offset,\n }\n if (input.locale !== undefined) findOptions.locale = input.locale\n\n const start = Date.now()\n const rows = await this.client.findMany(findOptions)\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n const durationMs = Date.now() - start\n\n const total = await this.client.count({ where } satisfies CountOptions)\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n\n return {\n rows: rows.map((row) => this.transform(row)),\n total,\n facets: [],\n durationMs,\n }\n }\n\n /**\n * Build the OR-combined query condition across `searchableFields`. Returns\n * `null` (not `undefined`) when no searchable column resolves — caller\n * shorts to an empty result rather than executing an unbounded query.\n */\n private buildQueryCondition(input: SearchInput): SQL | null {\n const table = this.client.getTable()\n const pieces: SQL[] = []\n const pattern = ilikePattern(input.query, input.mode)\n\n for (const field of this.searchableFields) {\n const column = resolveColumn(table, field)\n if (column === undefined) continue\n if (input.mode === 'term') {\n pieces.push(eq(column, input.query))\n } else {\n pieces.push(ilike(column, pattern))\n }\n }\n\n if (pieces.length === 0) return null\n const first = pieces[0]\n if (first === undefined) return null\n if (pieces.length === 1) return first\n // `or()` only returns undefined for zero args; we have ≥ 2 here.\n const combined = or(...pieces)\n if (combined === undefined) throw new Error('unreachable: or() called with ≥ 2 args')\n return combined\n }\n\n /**\n * Compile `filters` into AND-combined `eq` / `inArray` conditions.\n *\n * Field names are checked against `searchableFieldSet` — anything outside\n * is silently dropped. The mechanics layer already caps the number and\n * size of filters; this is the type-and-name validation step.\n */\n private buildFilterConditions(filters: SearchInput['filters']): SQL[] {\n const table = this.client.getTable()\n const out: SQL[] = []\n\n for (const [field, values] of Object.entries(filters)) {\n if (!this.filterableFieldSet.has(field)) continue\n const column = resolveColumn(table, field)\n if (column === undefined) continue\n if (values.length === 0) continue\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) continue\n out.push(eq(column, single))\n } else {\n out.push(inArray(column, [...values]))\n }\n }\n\n return out\n }\n}\n\nfunction ilikePattern(query: string, mode: SearchInput['mode']): string {\n const escaped = escapeLikePattern(query)\n if (mode === 'prefix') return `${escaped}%`\n // Default — phrase: substring match.\n return `%${escaped}%`\n}\n","import { SearchError } from '@murumets-ee/search'\nimport type {\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n} from '@murumets-ee/search'\n\n/**\n * Placeholder for a Postgres `tsvector` / `tsquery` provider.\n *\n * Reserved name + interface so that consumers and the registry already know\n * the shape; implementation lands when a first consumer needs ranking /\n * stemming on the Postgres tier (per PLAN-ECOMMERCE.md §6 \"Beyond PR 9\").\n *\n * Until then, calling `search()` throws — this is preferable to silently\n * falling back to ILIKE, which would mask the missing capability.\n */\nexport interface TsvectorProviderConfig {\n resource: string\n permissionResource?: string\n}\n\nconst TSVECTOR_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\nexport class TsvectorProvider implements SearchProvider {\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities = TSVECTOR_CAPABILITIES\n\n constructor(config: TsvectorProviderConfig) {\n this.resource = config.resource\n if (config.permissionResource !== undefined) {\n this.permissionResource = config.permissionResource\n }\n }\n\n search(_input: SearchInput, _signal: AbortSignal): Promise<SearchResult> {\n // 501 Not Implemented + typed `SearchError` so the admin route handler\n // maps cleanly to the status (otherwise it falls through to a 500).\n return Promise.reject(\n new SearchError(\n `TsvectorProvider '${this.resource}' is not implemented yet. ` +\n `Use IlikeProvider for substring search or wait for the Postgres FTS implementation.`,\n 501,\n 'not_implemented',\n ),\n )\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/search-postgres",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"drizzle-orm": "^0.45.2",
|
|
17
|
+
"@murumets-ee/core": "0.12.0",
|
|
18
|
+
"@murumets-ee/entity": "0.12.0",
|
|
19
|
+
"@murumets-ee/search": "0.12.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.19.39",
|
|
23
|
+
"tsdown": "^0.21.10",
|
|
24
|
+
"typescript": "^5.7.3",
|
|
25
|
+
"vitest": "^2.1.8"
|
|
26
|
+
},
|
|
27
|
+
"typeCoverage": {
|
|
28
|
+
"atLeast": 100
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsdown",
|
|
32
|
+
"dev": "tsdown --watch",
|
|
33
|
+
"test": "vitest"
|
|
34
|
+
}
|
|
35
|
+
}
|