@murumets-ee/search-postgres 0.16.0 → 0.16.2
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/dist/index.d.mts +25 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.d.mts
CHANGED
|
@@ -252,6 +252,19 @@ interface TsvectorProviderConfig {
|
|
|
252
252
|
* on the parent, not the included tables).
|
|
253
253
|
*/
|
|
254
254
|
defaultFilters?: Readonly<Record<string, readonly string[]>>;
|
|
255
|
+
/**
|
|
256
|
+
* Fields that should be OR-matched via ILIKE in addition to the
|
|
257
|
+
* tsvector match. These are the declared `searchable().fields` minus
|
|
258
|
+
* `searchable().fts.fields` — fields the consumer named as searchable
|
|
259
|
+
* but which don't participate in the FTS ranking (e.g. Ticket's
|
|
260
|
+
* `requesterEmail` / `requesterName` / `ticketNumber` when FTS is on
|
|
261
|
+
* subject only). Without this, typing a sender email returns nothing
|
|
262
|
+
* because the tsvector only covers subject.
|
|
263
|
+
*
|
|
264
|
+
* Each row matching ONLY via ILIKE (not via FTS) gets `score = 0`,
|
|
265
|
+
* so FTS-ranked rows still surface first.
|
|
266
|
+
*/
|
|
267
|
+
ilikeFields?: readonly string[];
|
|
255
268
|
/**
|
|
256
269
|
* Per D14, the provider does not synthesize a default projection.
|
|
257
270
|
* Each consumer maps its rows into `SearchResultRow` explicitly so
|
|
@@ -270,6 +283,7 @@ declare class TsvectorProvider implements SearchProvider {
|
|
|
270
283
|
private readonly transform;
|
|
271
284
|
private readonly includes;
|
|
272
285
|
private readonly defaultFilters;
|
|
286
|
+
private readonly ilikeFields;
|
|
273
287
|
constructor(config: TsvectorProviderConfig);
|
|
274
288
|
search(input: SearchInput, signal: AbortSignal): Promise<SearchResult>;
|
|
275
289
|
/**
|
|
@@ -290,6 +304,17 @@ declare class TsvectorProvider implements SearchProvider {
|
|
|
290
304
|
*/
|
|
291
305
|
private searchWithIncludes;
|
|
292
306
|
private resolveTsvCol;
|
|
307
|
+
/**
|
|
308
|
+
* Compose the row-match expression: tsvector match OR ILIKE on each
|
|
309
|
+
* configured `ilikeFields` entry. The user-typed query is escaped
|
|
310
|
+
* with `escapeLikePattern` before the `%...%` wrap so LIKE wildcards
|
|
311
|
+
* in input don't widen the match.
|
|
312
|
+
*
|
|
313
|
+
* Rows matching ONLY via ILIKE get `score = 0` from `ts_rank_cd`
|
|
314
|
+
* (FTS didn't match, so ranking is genuinely zero). FTS-ranked rows
|
|
315
|
+
* still surface first under `ORDER BY score DESC`.
|
|
316
|
+
*/
|
|
317
|
+
private buildMatchExpr;
|
|
293
318
|
private shapeResult;
|
|
294
319
|
/**
|
|
295
320
|
* Compose the full filter set: user-supplied filters from
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/bootstrap.ts","../src/ilike-provider.ts","../src/tsvector-provider.ts"],"mappings":";;;;;;UAkDiB,0BAAA;EC7Be;;;;;;EDoC9B,QAAA,WAAmB,MAAA;EChCI;;;;;;EDuCvB,cAAA,YAA0B,cAAA;EC1CkB;;;;EDgD5C,YAAA,IAAgB,MAAA,EAAQ,MAAA,UAAgB,WAAA;AAAA;;;;AC5CzC;;;;;AAUD;iBD4DgB,mBAAA,CAAoB,OAAA,EAAS,0BAAA,GAA6B,cAAA;;;;;;;;AA9C1E;;;;UC7BiB,eAAA;EACf,QAAA,CAAS,OAAA,EAAS,eAAA,GAAkB,OAAA,CAAQ,IAAA;EAC5C,KAAA,CAAM,OAAA,EAAS,YAAA,GAAe,OAAA;EAE9B,QAAA,IAAY,aAAA;AAAA;AAAA,KAIT,aAAA;EAAA,UAA0B,MAAA;AAAA;AAAA,UAOd,mBAAA;EDkCf;EChCA,QAAA;EDgCgB;;;;AA0BlB;ECpDE,kBAAA;;EAEA,MAAA,EAAQ,eAAA,CAAgB,IAAA;EDkDmB;;;;;EC5C3C,gBAAA;;;AA/BF;;;;;;;;;;;;EA8CE,gBAAA;EA7CkB;;;;;EAmDlB,SAAA,GAAY,GAAA,EAAK,IAAA,KAAS,eAAA;AAAA;;;;;;AA/C3B;;;;;AAUD;;;;;;;;cAkEa,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;EArE9C;;;;;EAAA,QAyHT,mBAAA;EA5FgB;;;;;;;EAAA,QA4HhB,qBAAA;AAAA;;;;AA7MV;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/bootstrap.ts","../src/ilike-provider.ts","../src/tsvector-provider.ts"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{createAdminClient as e}from"@murumets-ee/core/clients";import{getSearchableConfig as t}from"@murumets-ee/entity";import{SearchError as n,SearchErrorCodes as r,SearchRegistry as i}from"@murumets-ee/search";import{escapeLikePattern as a}from"@murumets-ee/core";import{and as o,asc as s,desc as c,eq as l,getTableColumns as u,ilike as d,inArray as f,or as p,sql as m}from"drizzle-orm";import{unionAll as h}from"drizzle-orm/pg-core";function g(e,t){return e[t]}const _={fullText:!1,ranking:!1,facets:!1,fuzzy:!1,prefix:!0};var v=class{resource;permissionResource;capabilities=_;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(g(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,t){if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let i=this.buildQueryCondition(e);if(i===null)return{rows:[],total:0,facets:[],durationMs:0};let a=o(i,...this.buildFilterConditions(e.filters));if(a===void 0)throw Error(`unreachable: and() called with at least one arg`);let s=a,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(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let d=Date.now()-l,f=await this.client.count({where:s});if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);return{rows:u.map(e=>this.transform(e)),total:f,facets:[],durationMs:d}}buildQueryCondition(e){let t=this.client.getTable(),n=[],r=y(e.query,e.mode);for(let i of this.searchableFields){let a=g(t,i);a!==void 0&&(e.mode===`term`?n.push(l(a,e.query)):n.push(d(a,r)))}if(n.length===0)return null;let i=n[0];if(i===void 0)return null;if(n.length===1)return i;let a=p(...n);if(a===void 0)throw Error(`unreachable: or() called with ≥ 2 args`);return a}buildFilterConditions(e){let t=this.client.getTable(),n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=g(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(l(e,t))}else n.push(f(e,[...i]))}return n}};function y(e,t){let n=a(e);return t===`prefix`?`${n}%`:`%${n}%`}function b(e,t){return e[t]}const x=`_search_tsv`,S=`simple`,C=`__lumi_score`,w=`__lumi_total`,T={fullText:!0,ranking:!0,facets:!1,fuzzy:!1,prefix:!0};var E=class{resource;permissionResource;capabilities=T;client;vectorColumn;language;filterableFieldSet;transform;includes;defaultFilters;constructor(e){let t=e.vectorColumn??`_search_tsv`,n=e.language??`simple`;if(e.entityScope!==void 0&&e.entityScope!==`global`)throw Error(`TsvectorProvider '${e.resource}' refuses entityScope='${e.entityScope}': v1 only supports 'global' (raw FTS query bypasses AdminClient scope-filter). Drop fts: {...} so searchable() falls through to IlikeProvider (which honors scope via AdminClient.findMany), or wait for scoped-FTS support.`);let r=e.client.getTable();if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' tsvector column '${t}' is not a column on the entity table`);for(let t of e.filterableFields)if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' filterable field '${t}' is not a column on the entity table`);for(let t of Object.keys(e.defaultFilters??{}))if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' defaultFilters field '${t}' is not a column on the entity table`);let i=[],a=e.includes??[],o=e.includedClients??{};for(let n of a){let r=o[n.entity];if(!r)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no entry in includedClients`);let a=r.getTable();if(b(a,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' is missing tsvector column '${t}' — it must have its own searchable({ fts: ... }) declaration`);if(b(a,n.fk)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no FK column '${n.fk}'`);i.push({entity:n.entity,fk:n.fk,client:r})}this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.client=e.client,this.vectorColumn=t,this.language=n,this.filterableFieldSet=new Set(e.filterableFields),this.transform=e.transform,this.includes=i,this.defaultFilters=e.defaultFilters??{}}async search(e,t){if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let i=this.includes.length===0?await this.searchSingleEntity(e):await this.searchWithIncludes(e);if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);return i}async searchSingleEntity(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=b(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=D(e.query,e.mode,this.language),a=m`ts_rank_cd(${n}, ${i})`,l=m`${n} @@ ${i}`,d=this.buildAllFilterConditions(e.filters,t),f=d.length===0?l:o(l,...d);if(f===void 0)throw Error(`unreachable: and() called with at least one arg`);let p=t,h=Date.now(),g=await this.client.getDb().select({...u(p),[C]:a,[w]:m`count(*) OVER ()::int`}).from(p).where(f).orderBy(c(a),s(r)).limit(e.limit).offset(e.offset),_=Date.now()-h;return this.shapeResult(g,_)}async searchWithIncludes(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=b(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=D(e.query,e.mode,this.language),a=this.client.getDb().select({parent_id:m`${r}`.as(`parent_id`),score:m`ts_rank_cd(${n}, ${i})`.as(`score`)}).from(t).where(m`${n} @@ ${i}`),d=this.includes.map(e=>{let t=e.client.getTable(),n=this.resolveTsvCol(t),r=b(t,e.fk);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' include '${e.entity}' lost FK column '${e.fk}' between construction and search`);return e.client.getDb().select({parent_id:m`${r}`.as(`parent_id`),score:m`MAX(ts_rank_cd(${n}, ${i}))`.as(`score`)}).from(t).where(m`${n} @@ ${i}`).groupBy(m`${r}`)}),f=d[0];if(!f)throw Error(`TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`);let p=h(a,f,...d.slice(1)).as(`union_hits`),g=this.client.getDb().select({parent_id:m`${p.parent_id}`.as(`parent_id`),score:m`MAX(${p.score})`.as(`score`)}).from(p).groupBy(p.parent_id).as(`h`),_=this.buildAllFilterConditions(e.filters,t),v=t,y=Date.now(),x=this.client.getDb().select({...u(v),[C]:m`${g.score}`,[w]:m`count(*) OVER ()::int`}).from(g).innerJoin(v,l(g.parent_id,r)),S=await(_.length===0?x:x.where(o(..._))).orderBy(c(g.score),s(r)).limit(e.limit).offset(e.offset),T=Date.now()-y;return this.shapeResult(S,T)}resolveTsvCol(e){let t=b(e,this.vectorColumn);if(t===void 0)throw Error(`TsvectorProvider '${this.resource}' tsvector column '${this.vectorColumn}' missing at search time`);return t}shapeResult(e,t){let n=O(e);return{rows:e.map(e=>{let t=this.transform(k(e)),n=A(e);return n===void 0?t:{...t,score:n}}),total:n,facets:[],durationMs:t}}buildAllFilterConditions(e,t){let n=[];n.push(...this.buildFilterConditions(e,t));let r=new Set(Object.keys(e));for(let[e,i]of Object.entries(this.defaultFilters)){if(r.has(e))continue;let a=this.buildSingleFilterCondition(e,i,t);a!==void 0&&n.push(a)}return n}buildSingleFilterCondition(e,t,n){let r=b(n,e);if(r!==void 0&&t.length!==0){if(t.length===1){let e=t[0];return e===void 0?void 0:l(r,e)}return f(r,[...t])}}buildFilterConditions(e,t){let n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=b(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(l(e,t))}else n.push(f(e,[...i]))}return n}};function D(e,t,n){switch(t){case`prefix`:{let t=e.split(/\s+/).map(e=>e.replace(/[&|!():*<>?\\'";]/g,``).trim()).filter(e=>e.length>0);return t.length===0?m`to_tsquery(${n}, ${`__no_match__:*`})`:m`to_tsquery(${n}, ${t.map(e=>`${e}:*`).join(` & `)})`}case`phrase`:return m`phraseto_tsquery(${n}, ${e})`;default:return m`websearch_to_tsquery(${n}, ${e})`}}function O(e){if(e.length===0)return 0;let t=e[0];if(!t)return 0;let n=t[w];if(typeof n==`number`)return n;if(typeof n==`string`){let e=Number(n);return Number.isFinite(e)?e:0}return 0}function k(e){let t={};for(let[n,r]of Object.entries(e))n===C||n===w||(t[n]=r);return t}function A(e){let t=e[C];if(typeof t==`number`)return t;if(typeof t==`string`){let e=Number(t);return Number.isFinite(e)?e:void 0}}function j(e){return t=>e(t)}function M(n){let r=new i,a=n.createClient??e,o=new Map;for(let e of n.entities){let n=t(e);if(!n)continue;let r=a(e);o.set(e.name,{entity:e,config:n,client:r})}for(let{entity:e,config:t,client:n}of o.values()){let i=t.fts&&F(e,t)?P(e,t,n,o):N(e,t,n);r.register(i)}for(let e of n.extraProviders??[])r.register(e);return r}function N(e,t,n){let r=n;return new v({resource:e.name,client:r,searchableFields:[...t.fields],transform:j(t.projection)})}function P(e,t,n,r){let i={};for(let n of t.includes??[]){let t=r.get(n.entity);if(!t)throw Error(`searchable() on entity "${e.name}" includes "${n.entity}", but "${n.entity}" has no searchable() declaration — add one, including fts: {...} so its _search_tsv column exists`);if(!t.config.fts)throw Error(`searchable() on entity "${e.name}" includes "${n.entity}", but "${n.entity}" has searchable() WITHOUT fts: {...} — UNION joins require the included entity to have an indexed tsvector column`);i[n.entity]=t.client}let a={resource:e.name,client:n,filterableFields:[...t.fields],transform:j(t.projection)};return e.scope!==void 0&&(a.entityScope=e.scope),t.fts?.language!==void 0&&(a.language=t.fts.language),t.includes&&t.includes.length>0&&(a.includes=t.includes.map(e=>({entity:e.entity,fk:e.fk})),a.includedClients=i),t.defaultFilters!==void 0&&(a.defaultFilters=t.defaultFilters),new E(a)}function F(e,t){if(!t.fts)return!1;let n=e.allFields;for(let e of t.fts.fields){let t=n[e];if(t&&!t.translatable)return!0}return!1}export{x as DEFAULT_TSVECTOR_COLUMN,S as DEFAULT_TSVECTOR_LANGUAGE,v as IlikeProvider,E as TsvectorProvider,M as buildSearchRegistry};
|
|
1
|
+
import{createAdminClient as e}from"@murumets-ee/core/clients";import{getSearchableConfig as t}from"@murumets-ee/entity";import{SearchError as n,SearchErrorCodes as r,SearchRegistry as i}from"@murumets-ee/search";import{escapeLikePattern as a}from"@murumets-ee/core";import{and as o,asc as s,desc as c,eq as l,getTableColumns as u,ilike as d,inArray as f,or as p,sql as m}from"drizzle-orm";import{unionAll as h}from"drizzle-orm/pg-core";function g(e,t){return e[t]}const _={fullText:!1,ranking:!1,facets:!1,fuzzy:!1,prefix:!0};var v=class{resource;permissionResource;capabilities=_;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(g(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,t){if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let i=this.buildQueryCondition(e);if(i===null)return{rows:[],total:0,facets:[],durationMs:0};let a=o(i,...this.buildFilterConditions(e.filters));if(a===void 0)throw Error(`unreachable: and() called with at least one arg`);let s=a,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(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let d=Date.now()-l,f=await this.client.count({where:s});if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);return{rows:u.map(e=>this.transform(e)),total:f,facets:[],durationMs:d}}buildQueryCondition(e){let t=this.client.getTable(),n=[],r=y(e.query,e.mode);for(let i of this.searchableFields){let a=g(t,i);a!==void 0&&(e.mode===`term`?n.push(l(a,e.query)):n.push(d(a,r)))}if(n.length===0)return null;let i=n[0];if(i===void 0)return null;if(n.length===1)return i;let a=p(...n);if(a===void 0)throw Error(`unreachable: or() called with ≥ 2 args`);return a}buildFilterConditions(e){let t=this.client.getTable(),n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=g(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(l(e,t))}else n.push(f(e,[...i]))}return n}};function y(e,t){let n=a(e);return t===`prefix`?`${n}%`:`%${n}%`}function b(e,t){return e[t]}const x=`_search_tsv`,S=`simple`,C=`__lumi_score`,w=`__lumi_total`,T={fullText:!0,ranking:!0,facets:!1,fuzzy:!1,prefix:!0};var E=class{resource;permissionResource;capabilities=T;client;vectorColumn;language;filterableFieldSet;transform;includes;defaultFilters;ilikeFields;constructor(e){let t=e.vectorColumn??`_search_tsv`,n=e.language??`simple`;if(e.entityScope!==void 0&&e.entityScope!==`global`)throw Error(`TsvectorProvider '${e.resource}' refuses entityScope='${e.entityScope}': v1 only supports 'global' (raw FTS query bypasses AdminClient scope-filter). Drop fts: {...} so searchable() falls through to IlikeProvider (which honors scope via AdminClient.findMany), or wait for scoped-FTS support.`);let r=e.client.getTable();if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' tsvector column '${t}' is not a column on the entity table`);for(let t of e.filterableFields)if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' filterable field '${t}' is not a column on the entity table`);for(let t of Object.keys(e.defaultFilters??{}))if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' defaultFilters field '${t}' is not a column on the entity table`);for(let t of e.ilikeFields??[])if(b(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' ilikeFields entry '${t}' is not a column on the entity table`);let i=[],a=e.includes??[],o=e.includedClients??{};for(let n of a){let r=o[n.entity];if(!r)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no entry in includedClients`);let a=r.getTable();if(b(a,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' is missing tsvector column '${t}' — it must have its own searchable({ fts: ... }) declaration`);if(b(a,n.fk)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no FK column '${n.fk}'`);i.push({entity:n.entity,fk:n.fk,client:r})}this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.client=e.client,this.vectorColumn=t,this.language=n,this.filterableFieldSet=new Set(e.filterableFields),this.transform=e.transform,this.includes=i,this.defaultFilters=e.defaultFilters??{},this.ilikeFields=e.ilikeFields??[]}async search(e,t){if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);let i=this.includes.length===0?await this.searchSingleEntity(e):await this.searchWithIncludes(e);if(t.aborted)throw new n(`Search aborted`,504,r.Timeout);return i}async searchSingleEntity(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=b(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=D(e.query,e.mode,this.language),a=m`ts_rank_cd(${n}, ${i})`,l=this.buildMatchExpr(t,n,i,e.query),d=this.buildAllFilterConditions(e.filters,t),f=d.length===0?l:o(l,...d);if(f===void 0)throw Error(`unreachable: and() called with at least one arg`);let p=t,h=Date.now(),g=await this.client.getDb().select({...u(p),[C]:a,[w]:m`count(*) OVER ()::int`}).from(p).where(f).orderBy(c(a),s(r)).limit(e.limit).offset(e.offset),_=Date.now()-h;return this.shapeResult(g,_)}async searchWithIncludes(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=b(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=D(e.query,e.mode,this.language),a=this.client.getDb().select({parent_id:m`${r}`.as(`parent_id`),score:m`ts_rank_cd(${n}, ${i})`.as(`score`)}).from(t).where(this.buildMatchExpr(t,n,i,e.query)),d=this.includes.map(e=>{let t=e.client.getTable(),n=this.resolveTsvCol(t),r=b(t,e.fk);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' include '${e.entity}' lost FK column '${e.fk}' between construction and search`);return e.client.getDb().select({parent_id:m`${r}`.as(`parent_id`),score:m`MAX(ts_rank_cd(${n}, ${i}))`.as(`score`)}).from(t).where(m`${n} @@ ${i}`).groupBy(m`${r}`)}),f=d[0];if(!f)throw Error(`TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`);let p=h(a,f,...d.slice(1)).as(`union_hits`),g=this.client.getDb().select({parent_id:m`${p.parent_id}`.as(`parent_id`),score:m`MAX(${p.score})`.as(`score`)}).from(p).groupBy(p.parent_id).as(`h`),_=this.buildAllFilterConditions(e.filters,t),v=t,y=Date.now(),x=this.client.getDb().select({...u(v),[C]:m`${g.score}`,[w]:m`count(*) OVER ()::int`}).from(g).innerJoin(v,l(g.parent_id,r)),S=await(_.length===0?x:x.where(o(..._))).orderBy(c(g.score),s(r)).limit(e.limit).offset(e.offset),T=Date.now()-y;return this.shapeResult(S,T)}resolveTsvCol(e){let t=b(e,this.vectorColumn);if(t===void 0)throw Error(`TsvectorProvider '${this.resource}' tsvector column '${this.vectorColumn}' missing at search time`);return t}buildMatchExpr(e,t,n,r){let i=m`${t} @@ ${n}`;if(this.ilikeFields.length===0)return i;let o=`%${a(r)}%`,s=[];for(let t of this.ilikeFields){let n=b(e,t);n!==void 0&&s.push(d(n,o))}if(s.length===0)return i;let c=s[0];if(!c)return i;let l=s.length===1?c:p(c,...s.slice(1));return l?p(i,l)??i:i}shapeResult(e,t){let n=O(e);return{rows:e.map(e=>{let t=this.transform(k(e)),n=A(e);return n===void 0?t:{...t,score:n}}),total:n,facets:[],durationMs:t}}buildAllFilterConditions(e,t){let n=[];n.push(...this.buildFilterConditions(e,t));let r=new Set(Object.keys(e));for(let[e,i]of Object.entries(this.defaultFilters)){if(r.has(e))continue;let a=this.buildSingleFilterCondition(e,i,t);a!==void 0&&n.push(a)}return n}buildSingleFilterCondition(e,t,n){let r=b(n,e);if(r!==void 0&&t.length!==0){if(t.length===1){let e=t[0];return e===void 0?void 0:l(r,e)}return f(r,[...t])}}buildFilterConditions(e,t){let n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=b(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(l(e,t))}else n.push(f(e,[...i]))}return n}};function D(e,t,n){switch(t){case`prefix`:{let t=e.split(/\s+/).map(e=>e.replace(/[&|!():*<>?\\'";]/g,``).trim()).filter(e=>e.length>0);return t.length===0?m`to_tsquery(${n}, ${`__no_match__:*`})`:m`to_tsquery(${n}, ${t.map(e=>`${e}:*`).join(` & `)})`}case`phrase`:return m`phraseto_tsquery(${n}, ${e})`;default:return m`websearch_to_tsquery(${n}, ${e})`}}function O(e){if(e.length===0)return 0;let t=e[0];if(!t)return 0;let n=t[w];if(typeof n==`number`)return n;if(typeof n==`string`){let e=Number(n);return Number.isFinite(e)?e:0}return 0}function k(e){let t={};for(let[n,r]of Object.entries(e))n===C||n===w||(t[n]=r);return t}function A(e){let t=e[C];if(typeof t==`number`)return t;if(typeof t==`string`){let e=Number(t);return Number.isFinite(e)?e:void 0}}function j(e){return t=>e(t)}function M(n){let r=new i,a=n.createClient??e,o=new Map;for(let e of n.entities){let n=t(e);if(!n)continue;let r=a(e);o.set(e.name,{entity:e,config:n,client:r})}for(let{entity:e,config:t,client:n}of o.values()){let i=t.fts&&F(e,t)?P(e,t,n,o):N(e,t,n);r.register(i)}for(let e of n.extraProviders??[])r.register(e);return r}function N(e,t,n){let r=n;return new v({resource:e.name,client:r,searchableFields:[...t.fields],transform:j(t.projection)})}function P(e,t,n,r){let i={};for(let n of t.includes??[]){let t=r.get(n.entity);if(!t)throw Error(`searchable() on entity "${e.name}" includes "${n.entity}", but "${n.entity}" has no searchable() declaration — add one, including fts: {...} so its _search_tsv column exists`);if(!t.config.fts)throw Error(`searchable() on entity "${e.name}" includes "${n.entity}", but "${n.entity}" has searchable() WITHOUT fts: {...} — UNION joins require the included entity to have an indexed tsvector column`);i[n.entity]=t.client}let a=t.fts?new Set(t.fts.fields):new Set,o=t.fields.filter(e=>!a.has(e)),s={resource:e.name,client:n,filterableFields:[...t.fields],ilikeFields:o,transform:j(t.projection)};return e.scope!==void 0&&(s.entityScope=e.scope),t.fts?.language!==void 0&&(s.language=t.fts.language),t.includes&&t.includes.length>0&&(s.includes=t.includes.map(e=>({entity:e.entity,fk:e.fk})),s.includedClients=i),t.defaultFilters!==void 0&&(s.defaultFilters=t.defaultFilters),new E(s)}function F(e,t){if(!t.fts)return!1;let n=e.allFields;for(let e of t.fts.fields){let t=n[e];if(t&&!t.translatable)return!0}return!1}export{x as DEFAULT_TSVECTOR_COLUMN,S as DEFAULT_TSVECTOR_LANGUAGE,v as IlikeProvider,E as TsvectorProvider,M as buildSearchRegistry};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["resolveColumn"],"sources":["../src/ilike-provider.ts","../src/tsvector-provider.ts","../src/bootstrap.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","/**\n * Postgres tsvector / tsquery search provider.\n *\n * Backs `searchable({ fts: ... })` entities at the `@murumets-ee/search`\n * tier — `IlikeProvider` for substring-only, `TsvectorProvider` for FTS\n * with relevance ranking. ES still earns its keep at the commerce parts\n * catalog tier (per PLAN-ECOMMERCE D16); everything transactional flows\n * through Postgres so reads come from the source of truth.\n *\n * Implementation choices, grounded in research (CLAUDE.md §\"Drizzle support\"):\n *\n * - **`websearch_to_tsquery`** is the canonical user-input parser\n * (Drizzle's official FTS guide). Quoted phrases, `OR` operators,\n * leading `-` for negation — all handled natively, no client-side\n * parsing. Used for `term` and `phrase` modes.\n * - **`to_tsquery` + `:*` suffix** for `prefix` mode (typeahead). The\n * input is tokenised + escaped before composing the `& :*` form to\n * keep `to_tsquery` from throwing on malformed input.\n * - **`ts_rank_cd`** (cover density) ranking. Weighted matches (A > B)\n * naturally rank higher — the `setweight()` calls in the GENERATED\n * tsvector column do the heavy lifting; this provider just orders by\n * the resulting score.\n * - **`count(*) OVER ()`** window function for total. Single round-trip,\n * no separate count query, ~10-20% query overhead. Caveat: when\n * `offset >= total` the LIMIT/OFFSET drops every row so we have no\n * row to read the window count from — `total` is reported as `0` in\n * that contrived edge case. Real UIs don't paginate past the end; if\n * they do, the user gets a single misleading \"no results\" page until\n * they re-search.\n *\n * Single-entity mode only in this commit (#3). Cross-entity `includes`\n * JOIN composition lands in commit #5 via the UNION ALL + GROUP BY shape\n * specified in the plan.\n */\n\nimport type {\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport {\n and,\n asc,\n type Column,\n desc,\n eq,\n getTableColumns,\n inArray,\n type SQL,\n sql,\n} from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\nimport { unionAll } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\n/**\n * Minimal `AdminClient` shape the provider needs. Extends the\n * {@link IlikeProvider}'s shape with `getDb()` so we can drop down to\n * Drizzle's typed query builder for the FTS query (which `findMany`\n * cannot express because of `ts_rank_cd` / `@@` / window functions).\n *\n * `getTable()` returns the same dynamic-column shape as the public\n * `AdminClient.getTable()` — column refs are typed `any` from the\n * provider's perspective, which is the same trade-off the rest of the\n * search-postgres code makes.\n */\nexport interface TsvectorAdminClientLike {\n /**\n * The Drizzle table for the indexed entity. Must include the tsvector\n * column declared by `searchable({ fts: ... })` (default name\n * `_search_tsv`).\n */\n getTable(): PgTableLike\n /**\n * Drizzle database handle. Provider uses it to run the FTS SELECT\n * (which needs `ts_rank_cd` + window count — not expressible via\n * `findMany`).\n */\n getDb(): PostgresJsDatabase\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\n/** Default name for the GENERATED tsvector column produced by `searchable()`. */\nexport const DEFAULT_TSVECTOR_COLUMN = '_search_tsv'\n\n/** Default Postgres text-search configuration. `simple` is locale-neutral. */\nexport const DEFAULT_TSVECTOR_LANGUAGE = 'simple'\n\n/**\n * Internal column names used to smuggle the per-row score and the\n * window-function row count through the typed select. Namespaced with\n * `__lumi_` so they can't collide with real entity columns — both\n * `_score` and `_total` were too easy to overlap with user-defined\n * fields, and the `getTableColumns(...)`-spread pattern would silently\n * overwrite the real value with our synthetic one.\n */\nconst SCORE_KEY = '__lumi_score'\nconst TOTAL_KEY = '__lumi_total'\n\n/**\n * Cross-entity OR-search target. Declares that the provider should UNION\n * the included entity's tsvector hits into the parent's result set —\n * the canonical example is searching Ticket subjects AND TicketMessage\n * bodies and getting a single per-ticket result.\n *\n * The included entity MUST have its own `searchable({ fts: ... })`\n * declaration (so its `_search_tsv` column exists) and an\n * `AdminClient` provided via `TsvectorProviderConfig.includedClients`.\n */\nexport interface TsvectorInclude {\n /** Entity name of the related table (matches `defineEntity.name`). */\n entity: string\n /**\n * Foreign-key column on the included entity that points back to the\n * parent's `id`. Used as `parent_id` in the UNION subquery.\n */\n fk: string\n}\n\nexport interface TsvectorProviderConfig {\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`.\n */\n permissionResource?: string\n /**\n * The indexed entity's scope: `'global'` | `'team'` | `'user'`. Required\n * because the provider drops to raw Drizzle for FTS (`ts_rank_cd` +\n * window-count can't be expressed via `AdminClient.findMany`) and so\n * skips the scope-filter that `AdminClient` would otherwise add.\n *\n * v1 ONLY supports `'global'` — the constructor throws on anything else.\n * Scoped FTS needs a per-request `_scopeId` resolver wired into the\n * provider's WHERE clause; that work is a follow-up PR. Until then,\n * pair scoped entities with `searchable()` WITHOUT `fts` so they fall\n * through to `IlikeProvider`, which goes via `AdminClient.findMany` and\n * inherits scope filtering automatically.\n */\n entityScope?: 'global' | 'team' | 'user'\n /** AdminClient for the indexed entity. */\n client: TsvectorAdminClientLike\n /**\n * Postgres column name on the entity table that holds the tsvector.\n * Must exist; the constructor throws on a typo. Defaults to\n * `_search_tsv` — matches the column generated by\n * `searchable({ fts: ... })` in commit #4.\n */\n vectorColumn?: string\n /**\n * Postgres text-search configuration. Drives the GENERATED column\n * expression at write time AND the `websearch_to_tsquery` /\n * `to_tsquery` calls at read time — the two MUST match or rows won't\n * match their own indexed tokens. Defaults to `simple`.\n */\n language?: string\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`.\n * Names outside the set are silently dropped — the only line of\n * defense against the documented \"arbitrary user-supplied field\n * name\" injection vector on `FacetFilters`.\n */\n filterableFields: readonly string[]\n /**\n * Cross-entity includes. Each included entity must have its own\n * `_search_tsv` column AND be exposed via `includedClients` so the\n * provider can resolve the table at search time.\n */\n includes?: readonly TsvectorInclude[]\n /**\n * AdminClients for each entity referenced in `includes`. Keyed by\n * entity name. Required when `includes` is non-empty.\n */\n includedClients?: Readonly<Record<string, TsvectorAdminClientLike>>\n /**\n * Default filters applied on the OUTER select — re-applied AFTER the\n * UNION so a body hit on a closed ticket does not leak through. The\n * inner per-table CTEs do NOT apply these filters (they're row-level\n * on the parent, not the included tables).\n */\n defaultFilters?: Readonly<Record<string, readonly string[]>>\n /**\n * Per D14, the provider does not synthesize a default projection.\n * Each consumer maps its rows into `SearchResultRow` explicitly so\n * `label` / `description` / `url` come from the right fields.\n */\n transform: (row: Record<string, unknown>) => SearchResultRow\n}\n\nconst TSVECTOR_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\n/**\n * Provider implementing FTS over a Postgres `tsvector` column.\n *\n * Wire it via `searchable({ fts: ... })` on the entity, then register\n * an instance in the admin `SearchRegistry` during plugin bootstrap\n * (commit #6).\n */\ninterface ResolvedInclude {\n entity: string\n fk: string\n client: TsvectorAdminClientLike\n}\n\nexport class TsvectorProvider implements SearchProvider {\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities = TSVECTOR_CAPABILITIES\n\n private readonly client: TsvectorAdminClientLike\n private readonly vectorColumn: string\n private readonly language: string\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly transform: (row: Record<string, unknown>) => SearchResultRow\n private readonly includes: readonly ResolvedInclude[]\n private readonly defaultFilters: Readonly<Record<string, readonly string[]>>\n\n constructor(config: TsvectorProviderConfig) {\n const vectorColumn = config.vectorColumn ?? DEFAULT_TSVECTOR_COLUMN\n const language = config.language ?? DEFAULT_TSVECTOR_LANGUAGE\n\n // Refuse non-global scope: TsvectorProvider bypasses AdminClient's\n // built-in scope filter (raw Drizzle select for FTS), so a `'team'` /\n // `'user'` entity would silently leak cross-tenant rows through search.\n // See the `entityScope` field doc for the workaround.\n if (config.entityScope !== undefined && config.entityScope !== 'global') {\n throw new Error(\n `TsvectorProvider '${config.resource}' refuses entityScope='${config.entityScope}': v1 only supports 'global' (raw FTS query bypasses AdminClient scope-filter). Drop fts: {...} so searchable() falls through to IlikeProvider (which honors scope via AdminClient.findMany), or wait for scoped-FTS support.`,\n )\n }\n\n const table = config.client.getTable()\n if (resolveColumn(table, vectorColumn) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' tsvector column '${vectorColumn}' is not a column on the entity table`,\n )\n }\n for (const field of config.filterableFields) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' filterable field '${field}' is not a column on the entity table`,\n )\n }\n }\n // `defaultFilters` field names must resolve to real columns on the\n // entity table. A typo here is exactly the bug class `filterableFields`\n // already guards against — silently disabling a security/UX guardrail\n // (e.g. \"exclude closed tickets\") because `resolveColumn` returns\n // undefined and `buildDefaultFilterConditions` skips the unknown field.\n for (const field of Object.keys(config.defaultFilters ?? {})) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' defaultFilters field '${field}' is not a column on the entity table`,\n )\n }\n }\n\n // Resolve includes: every included entity must (a) appear in\n // includedClients, (b) have the configured tsvector column, (c)\n // have the declared FK column. Failing fast here means we never\n // emit a UNION query with a column lookup that returns undefined.\n const includes: ResolvedInclude[] = []\n const rawIncludes = config.includes ?? []\n const includedClients = config.includedClients ?? {}\n for (const inc of rawIncludes) {\n const incClient = includedClients[inc.entity]\n if (!incClient) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' has no entry in includedClients`,\n )\n }\n const incTable = incClient.getTable()\n if (resolveColumn(incTable, vectorColumn) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' is missing tsvector column '${vectorColumn}' — it must have its own searchable({ fts: ... }) declaration`,\n )\n }\n if (resolveColumn(incTable, inc.fk) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' has no FK column '${inc.fk}'`,\n )\n }\n includes.push({ entity: inc.entity, fk: inc.fk, client: incClient })\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.vectorColumn = vectorColumn\n this.language = language\n this.filterableFieldSet = new Set(config.filterableFields)\n this.transform = config.transform\n this.includes = includes\n this.defaultFilters = config.defaultFilters ?? {}\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 const result =\n this.includes.length === 0\n ? await this.searchSingleEntity(input)\n : await this.searchWithIncludes(input)\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n return result\n }\n\n /**\n * Single-entity FTS query. No JOIN, no UNION — just a `WHERE tsv @@ q`\n * scan over the main table with `ts_rank_cd` ordering and a window\n * count for `total`.\n */\n private async searchSingleEntity(input: SearchInput): Promise<SearchResult> {\n const table = this.client.getTable()\n const tsvCol = this.resolveTsvCol(table)\n const idCol = resolveColumn(table, 'id')\n if (idCol === undefined) {\n throw new Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`)\n }\n\n const tsQueryExpr = buildTsQuery(input.query, input.mode, this.language)\n const rankExpr = sql<number>`ts_rank_cd(${tsvCol}, ${tsQueryExpr})`\n const matchExpr = sql`${tsvCol} @@ ${tsQueryExpr}`\n\n const filterConditions = this.buildAllFilterConditions(input.filters, table)\n const whereExpr =\n filterConditions.length === 0 ? matchExpr : and(matchExpr, ...filterConditions)\n if (whereExpr === undefined) {\n throw new Error('unreachable: and() called with at least one arg')\n }\n\n const drizzleTable = table as unknown as PgTable\n const start = Date.now()\n const rows = await this.client\n .getDb()\n .select({\n ...getTableColumns(drizzleTable),\n [SCORE_KEY]: rankExpr,\n [TOTAL_KEY]: sql<number>`count(*) OVER ()::int`,\n })\n .from(drizzleTable)\n .where(whereExpr)\n .orderBy(desc(rankExpr), asc(idCol))\n .limit(input.limit)\n .offset(input.offset)\n\n const durationMs = Date.now() - start\n return this.shapeResult(rows, durationMs)\n }\n\n /**\n * Cross-entity FTS via UNION ALL + GROUP BY hits subquery. Each\n * configured include contributes its own per-parent rank set; the\n * outer GROUP BY collapses duplicates and keeps the highest score\n * per parent.\n *\n * Default filters are re-applied on the outer SELECT — a body hit on\n * an archived ticket would otherwise leak through because the per-\n * include CTE only sees the related table's columns.\n */\n private async searchWithIncludes(input: SearchInput): Promise<SearchResult> {\n const mainTable = this.client.getTable()\n const mainTsvCol = this.resolveTsvCol(mainTable)\n const mainIdCol = resolveColumn(mainTable, 'id')\n if (mainIdCol === undefined) {\n throw new Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`)\n }\n\n const tsQueryExpr = buildTsQuery(input.query, input.mode, this.language)\n\n // Per-table hit selects, all aliased into a uniform\n // (parent_id, score) shape so unionAll types align.\n const mainSelect = this.client\n .getDb()\n .select({\n parent_id: sql<string>`${mainIdCol}`.as('parent_id'),\n score: sql<number>`ts_rank_cd(${mainTsvCol}, ${tsQueryExpr})`.as('score'),\n })\n .from(mainTable as unknown as PgTable)\n .where(sql`${mainTsvCol} @@ ${tsQueryExpr}`)\n\n const includeSelects = this.includes.map((inc) => {\n const incTable = inc.client.getTable()\n const incTsvCol = this.resolveTsvCol(incTable)\n const fkCol = resolveColumn(incTable, inc.fk)\n if (fkCol === undefined) {\n throw new Error(\n `TsvectorProvider '${this.resource}' include '${inc.entity}' lost FK column '${inc.fk}' between construction and search`,\n )\n }\n // Use the included entity's OWN db handle for the per-table SELECT\n // builder. NOTE: at execute time Drizzle's `unionAll(left, right)`\n // composes both into the LEFT select's connection — the right\n // side's bound db is ignored. So this is a single-connection query\n // in practice. The `inc.client.getDb()` call is kept as a\n // documentation signal: each include's per-table query is logically\n // scoped to its own entity's client, even though the SQL is\n // executed via the parent's connection. If a future setup wires\n // cross-connection UNION (separate queries + in-memory merge),\n // this is the read path that already names the right connection.\n return inc.client\n .getDb()\n .select({\n parent_id: sql<string>`${fkCol}`.as('parent_id'),\n score: sql<number>`MAX(ts_rank_cd(${incTsvCol}, ${tsQueryExpr}))`.as('score'),\n })\n .from(incTable as unknown as PgTable)\n .where(sql`${incTsvCol} @@ ${tsQueryExpr}`)\n .groupBy(sql`${fkCol}`)\n })\n\n // Drizzle's `unionAll(leftSelect, rightSelect, ...rest)` is variadic\n // but its overload signature is awkward to type when `rest` comes\n // from a `.map()`. Splitting into head + tail removes the need for\n // an `any` cast — `includes.length > 0` is enforced by the\n // dispatch in `search()`, so `includeSelects[0]` is always defined.\n const firstInclude = includeSelects[0]\n if (!firstInclude) {\n // Unreachable: searchWithIncludes is only called when includes.length > 0.\n throw new Error(\n `TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`,\n )\n }\n const hitsUnion = unionAll(mainSelect, firstInclude, ...includeSelects.slice(1)).as(\n 'union_hits',\n )\n\n // Outer dedupe: collapse per parent_id, keep max score.\n const dedupedSubquery = this.client\n .getDb()\n .select({\n parent_id: sql<string>`${hitsUnion.parent_id}`.as('parent_id'),\n score: sql<number>`MAX(${hitsUnion.score})`.as('score'),\n })\n .from(hitsUnion)\n .groupBy(hitsUnion.parent_id)\n .as('h')\n\n // Re-apply default + user filters on the outer SELECT so an\n // include-only hit still respects per-parent constraints.\n const outerFilters = this.buildAllFilterConditions(input.filters, mainTable)\n const drizzleMain = mainTable as unknown as PgTable\n const start = Date.now()\n\n const baseSelect = this.client\n .getDb()\n .select({\n ...getTableColumns(drizzleMain),\n [SCORE_KEY]: sql<number>`${dedupedSubquery.score}`,\n // `count(*) OVER ()` returns int8; cast to int4 so postgres-js\n // returns a JS number (default OID parsers cover int4/int2/oid/\n // float4/float8 but NOT int8). Without `::int` postgres-js leaves\n // it as a string and `typeof total === 'number'` is false →\n // every page returns total=0.\n [TOTAL_KEY]: sql<number>`count(*) OVER ()::int`,\n })\n .from(dedupedSubquery)\n .innerJoin(drizzleMain, eq(dedupedSubquery.parent_id, mainIdCol))\n\n const ordered = (\n outerFilters.length === 0 ? baseSelect : baseSelect.where(and(...outerFilters))\n )\n .orderBy(desc(dedupedSubquery.score), asc(mainIdCol))\n .limit(input.limit)\n .offset(input.offset)\n\n const rows = await ordered\n const durationMs = Date.now() - start\n return this.shapeResult(rows, durationMs)\n }\n\n private resolveTsvCol(table: PgTableLike): Column {\n const col = resolveColumn(table, this.vectorColumn)\n if (col === undefined) {\n throw new Error(\n `TsvectorProvider '${this.resource}' tsvector column '${this.vectorColumn}' missing at search time`,\n )\n }\n return col\n }\n\n private shapeResult(rows: Record<string, unknown>[], durationMs: number): SearchResult {\n const total = readTotal(rows)\n const resultRows = rows.map((row) => {\n const projected = this.transform(stripWindowColumns(row))\n const score = pickScore(row)\n return score !== undefined ? { ...projected, score } : projected\n })\n return { rows: resultRows, total, facets: [], durationMs }\n }\n\n /**\n * Compose the full filter set: user-supplied filters from\n * `SearchInput.filters` (narrowed to `filterableFields`) plus the\n * provider's own `defaultFilters`. Per-key REPLACE semantics — if the\n * caller passes any value for a key that has a default, the user's\n * value wins for that key and the default is dropped. Defaults still\n * apply to every key the user did NOT mention. This matches the\n * Zendesk-style \"search active tickets by default, but `?f.status=\n * closed` surfaces closed tickets\" UX. AND-ing both sets (the v1\n * behavior) made user filters incapable of broadening the live set,\n * which broke the documented \"surface closed tickets\" path.\n */\n private buildAllFilterConditions(\n inputFilters: SearchInput['filters'],\n table: PgTableLike,\n ): SQL[] {\n const out: SQL[] = []\n out.push(...this.buildFilterConditions(inputFilters, table))\n const userFilterKeys = new Set(Object.keys(inputFilters))\n for (const [field, values] of Object.entries(this.defaultFilters)) {\n if (userFilterKeys.has(field)) continue\n const condition = this.buildSingleFilterCondition(field, values, table)\n if (condition !== undefined) out.push(condition)\n }\n return out\n }\n\n /**\n * Compile a single field/values pair into the `eq` or `inArray`\n * condition the WHERE clause expects. Returns `undefined` when the\n * column does not resolve or `values` is empty — callers skip in that\n * case rather than emit a degenerate `WHERE col IN ()`.\n */\n private buildSingleFilterCondition(\n field: string,\n values: readonly string[],\n table: PgTableLike,\n ): SQL | undefined {\n const column = resolveColumn(table, field)\n if (column === undefined) return undefined\n if (values.length === 0) return undefined\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) return undefined\n return eq(column, single)\n }\n return inArray(column, [...values])\n }\n\n /**\n * Compile `filters` into AND-combined `eq` / `inArray` conditions.\n * Field names outside `filterableFields` are silently dropped — the\n * defense against the FacetFilters injection vector.\n */\n private buildFilterConditions(filters: SearchInput['filters'], table: PgTableLike): SQL[] {\n const out: SQL[] = []\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 return out\n }\n}\n\n/**\n * Build the per-mode tsquery expression.\n *\n * The three modes serve distinct UX shapes:\n *\n * - `term` → `websearch_to_tsquery` — handles quoted phrases, OR /\n * negation operators, raw user input. Postgres-native parser,\n * never throws on malformed input.\n * - `prefix` → `to_tsquery(...)` with each token suffixed `:*` so\n * typeahead matches partial words. Special-character\n * escaping required because `to_tsquery` IS strict; we\n * strip tsquery operators from each token before joining.\n * - `phrase` → `phraseto_tsquery` — strict word-adjacency match. Less\n * permissive than websearch but matches the documented\n * `phrase` mode semantics in `@murumets-ee/search`.\n *\n * @internal exported for the unit test that asserts compiled SQL per mode.\n */\nexport function buildTsQuery(query: string, mode: SearchInput['mode'], language: string): SQL {\n switch (mode) {\n case 'prefix': {\n // Tokenize on whitespace, escape tsquery operators + special-form\n // characters from each token, suffix `:*`, AND them together.\n // The full strip-set:\n // & | ! — tsquery boolean operators\n // ( ) — grouping\n // : * — weight / prefix-marker (replaced with `:*` ourselves)\n // < > — distance operators (`<-> ` etc.)\n // ? — tsquery wildcard\n // \\ — escape character (causes to_tsquery to throw on\n // trailing backslash; values are bind-parameterized\n // so SQL injection is impossible, but a 500 is\n // worse UX than a no-match)\n // ' \" — unbalanced quotes break the parser the same way\n // ; — defense-in-depth (parser already tolerates it)\n const tokens = query\n .split(/\\s+/)\n .map((t) => t.replace(/[&|!():*<>?\\\\'\";]/g, '').trim())\n .filter((t) => t.length > 0)\n if (tokens.length === 0) {\n // tsquery 'null' (literal token unlikely to match) keeps the\n // semantics of \"no match\" without the `to_tsquery` parser\n // throwing on an empty string.\n return sql`to_tsquery(${language}, ${'__no_match__:*'})`\n }\n const tsqueryText = tokens.map((t) => `${t}:*`).join(' & ')\n return sql`to_tsquery(${language}, ${tsqueryText})`\n }\n case 'phrase':\n return sql`phraseto_tsquery(${language}, ${query})`\n default:\n return sql`websearch_to_tsquery(${language}, ${query})`\n }\n}\n\n/**\n * Extract the window-count total from the result rows. Returns `0` when\n * the result set is empty — see the JSDoc edge case at the top of file.\n *\n * `Number()` coerces strings — defensive: `count(*) OVER ()::int` should\n * already arrive as a number from postgres-js (int4 has a default\n * parser), but if anyone removes the `::int` cast in the future they\n * get a degraded total rather than total=0.\n */\nfunction readTotal(rows: ReadonlyArray<Record<string, unknown>>): number {\n if (rows.length === 0) return 0\n const first = rows[0]\n if (!first) return 0\n const total = first[TOTAL_KEY]\n if (typeof total === 'number') return total\n if (typeof total === 'string') {\n const parsed = Number(total)\n return Number.isFinite(parsed) ? parsed : 0\n }\n return 0\n}\n\n/**\n * Strip the synthetic per-row score and total columns before passing\n * the row to the user-supplied `transform()`. Namespaced keys avoid\n * collisions with real entity columns (see `SCORE_KEY` / `TOTAL_KEY`).\n */\nfunction stripWindowColumns(row: Record<string, unknown>): Record<string, unknown> {\n const rest: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(row)) {\n if (key === SCORE_KEY || key === TOTAL_KEY) continue\n rest[key] = value\n }\n return rest\n}\n\n/** Read the per-row `ts_rank_cd` score for the SearchResultRow `score` field. */\nfunction pickScore(row: Record<string, unknown>): number | undefined {\n const value = row[SCORE_KEY]\n if (typeof value === 'number') return value\n if (typeof value === 'string') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n }\n return undefined\n}\n","/**\n * Admin search bootstrap — turn a list of entities into a `SearchRegistry`\n * populated with one provider per `searchable()`-declaring entity.\n *\n * Consumer pattern (one call in the admin API handler setup):\n *\n * ```ts\n * import { buildSearchRegistry } from '@murumets-ee/search-postgres'\n * import { searchRoutes, proxyClientIp } from '@murumets-ee/search/admin'\n *\n * const searchRegistry = buildSearchRegistry({ entities: crudEntities })\n *\n * const routes = [\n * ...\n * searchRoutes({ registry: searchRegistry, getClientIp: proxyClientIp }),\n * ]\n * ```\n *\n * Bootstrap is split into two passes so cross-entity `includes` references\n * can resolve to other entities' `AdminClient` instances regardless of\n * declaration order:\n *\n * 1. Build the `AdminClient` for every entity declaring `searchable()`.\n * 2. Build provider instances — for entities with `fts`, attach\n * `includedClients` resolved from pass 1. For entities without `fts`,\n * fall back to `IlikeProvider` (substring match across declared fields).\n *\n * Cross-entity entities are rejected when:\n * - the included entity does not declare `searchable()`\n * - the included entity has no `fts` (no `_search_tsv` column → no\n * way to UNION its hits)\n *\n * Both cases throw with a clear message at bootstrap time. The pattern\n * is \"fail loud\" — silent misconfigurations would produce empty search\n * results, which is worse than a startup error.\n */\n\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport {\n type Entity,\n getSearchableConfig,\n type SearchableConfig,\n type SearchableResultRow,\n} from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { type SearchProvider, SearchRegistry, type SearchResultRow } from '@murumets-ee/search'\nimport { type AdminClientLike, IlikeProvider } from './ilike-provider'\nimport { type TsvectorAdminClientLike, TsvectorProvider } from './tsvector-provider'\n\nexport interface BuildSearchRegistryOptions {\n /**\n * All entities the admin UI exposes. Entities without a `searchable()`\n * behavior are skipped (no provider registered → admin search route\n * returns 404 for that resource, EntityList toolbar disables the\n * search input).\n */\n entities: readonly Entity[]\n /**\n * Additional pre-built providers to register alongside the entity-\n * derived ones. Use this for non-`defineEntity` resources — the\n * canonical case is commerce parts catalog (Elasticsearch-backed,\n * registered under its own resource name).\n */\n extraProviders?: readonly SearchProvider[]\n /**\n * Test seam — override `createAdminClient`. Defaults to the production\n * factory, which reads the current `ToolkitApp` from AsyncLocalStorage.\n */\n // biome-ignore lint/suspicious/noExplicitAny: caller-defined entity field shapes — test seam only\n createClient?: (entity: Entity<any>) => AdminClient<any>\n}\n\n/**\n * Project an entity row → `SearchResultRow`. The provider config types\n * `transform` as `(row) => SearchResultRow`; the searchable() config\n * holds `(row) => SearchableResultRow`. The two types are structurally\n * identical (designed that way, see entity-package note in\n * `searchable.ts`) — this adapter just narrows the variance so providers\n * see the right type.\n */\nfunction adaptProjection(\n projection: (row: Record<string, unknown>) => SearchableResultRow,\n): (row: Record<string, unknown>) => SearchResultRow {\n return (row) => projection(row)\n}\n\n/**\n * Build a `SearchRegistry` populated with one provider per\n * `searchable()`-declaring entity.\n *\n * Pass 1 builds `AdminClient`s for every searchable entity. Pass 2\n * constructs provider instances, resolving cross-entity `includes`\n * against the pass-1 map. Both passes throw on misconfiguration —\n * silent gaps are not acceptable for an admin search system.\n */\nexport function buildSearchRegistry(options: BuildSearchRegistryOptions): SearchRegistry {\n const registry = new SearchRegistry()\n const createClient = options.createClient ?? createAdminClient\n\n // Pass 1 — build clients for every entity that opts in.\n const searchableEntities = new Map<\n string,\n {\n entity: Entity\n config: SearchableConfig\n client: AdminClient\n }\n >()\n\n for (const entity of options.entities) {\n const config = getSearchableConfig(entity)\n if (!config) continue\n const client = createClient(entity) as AdminClient\n searchableEntities.set(entity.name, { entity, config, client })\n }\n\n // Pass 2 — build providers, resolving includes against the pass-1 map.\n for (const { entity, config, client } of searchableEntities.values()) {\n // Translatable-only fts entities (e.g. Article with title/excerpt/body\n // all translatable) have their `_search_tsv` column on the\n // `<entity>_translations` table, NOT the main table. TsvectorProvider's\n // constructor checks the main table and would throw. v1 does not yet\n // support translation-table FTS via the dedicated route — fall back\n // to IlikeProvider so search still works (substring across declared\n // `fields` AND translatable fields' translation rows are reachable\n // via the existing translation table joins). When translation-table\n // FTS lands, this branch goes away.\n const provider =\n config.fts && hasNonTranslatableFtsField(entity, config)\n ? buildTsvectorProvider(entity, config, client, searchableEntities)\n : buildIlikeProvider(entity, config, client)\n registry.register(provider)\n }\n\n // Register extras (commerce parts ES, etc.) after entity-derived\n // providers. `SearchRegistry.register()` throws on resource-name\n // collision — intentional fail-loud, NOT \"extras shadow entities\".\n // If an extra and an entity-derived provider share a resource name,\n // boot fails with a clear duplicate-registration error; resolve by\n // renaming the resource on one side.\n for (const extra of options.extraProviders ?? []) {\n registry.register(extra)\n }\n\n return registry\n}\n\n/**\n * Build an `IlikeProvider` from an entity's `searchable({ fields })`\n * declaration. Used when no `fts` is configured — substring match across\n * declared fields, no ranking.\n */\nfunction buildIlikeProvider(\n entity: Entity,\n config: SearchableConfig,\n client: AdminClient,\n): SearchProvider {\n // `IlikeProvider` expects `AdminClientLike<TRow>`. The AdminClient\n // shape extends it; the cast is structural and safe.\n const adminLike = client as unknown as AdminClientLike<Record<string, unknown>>\n return new IlikeProvider<Record<string, unknown>>({\n resource: entity.name,\n client: adminLike,\n searchableFields: [...config.fields],\n transform: adaptProjection(config.projection),\n })\n}\n\n/**\n * Build a `TsvectorProvider` from an entity's `searchable({ fts, includes? })`\n * declaration. Cross-entity `includes` are resolved to their AdminClients\n * — every included entity must itself declare `searchable({ fts: ... })`\n * so its `_search_tsv` column exists.\n */\nfunction buildTsvectorProvider(\n entity: Entity,\n config: SearchableConfig,\n client: AdminClient,\n searchableEntities: Map<\n string,\n {\n entity: Entity\n config: SearchableConfig\n client: AdminClient\n }\n >,\n): SearchProvider {\n const includedClients: Record<string, TsvectorAdminClientLike> = {}\n for (const inc of config.includes ?? []) {\n const target = searchableEntities.get(inc.entity)\n if (!target) {\n throw new Error(\n `searchable() on entity \"${entity.name}\" includes \"${inc.entity}\", but \"${inc.entity}\" has no searchable() declaration — add one, including fts: {...} so its _search_tsv column exists`,\n )\n }\n if (!target.config.fts) {\n throw new Error(\n `searchable() on entity \"${entity.name}\" includes \"${inc.entity}\", but \"${inc.entity}\" has searchable() WITHOUT fts: {...} — UNION joins require the included entity to have an indexed tsvector column`,\n )\n }\n includedClients[inc.entity] = target.client as unknown as TsvectorAdminClientLike\n }\n\n // Build the provider config piece by piece so optional fields don't\n // smuggle `undefined` past `exactOptionalPropertyTypes`.\n const providerConfig: ConstructorParameters<typeof TsvectorProvider>[0] = {\n resource: entity.name,\n client: client as unknown as TsvectorAdminClientLike,\n filterableFields: [...config.fields],\n transform: adaptProjection(config.projection),\n }\n // Forward the entity's scope so TsvectorProvider can refuse non-global\n // scoped entities at construction. v1 doesn't have a scoped-FTS path;\n // an unguarded scoped entity would silently leak cross-tenant rows.\n if (entity.scope !== undefined) {\n providerConfig.entityScope = entity.scope\n }\n if (config.fts?.language !== undefined) {\n providerConfig.language = config.fts.language\n }\n if (config.includes && config.includes.length > 0) {\n providerConfig.includes = config.includes.map((inc) => ({\n entity: inc.entity,\n fk: inc.fk,\n }))\n providerConfig.includedClients = includedClients\n }\n if (config.defaultFilters !== undefined) {\n providerConfig.defaultFilters = config.defaultFilters\n }\n return new TsvectorProvider(providerConfig)\n}\n\n/**\n * Whether the entity has at least one `fts.field` that lives on the main\n * table (i.e. NOT translatable). Determines provider choice: when every\n * `fts.field` is translatable the main table has no `_search_tsv`, so\n * `TsvectorProvider`'s constructor — which checks the main table — would\n * throw. The dedicated `/api/admin/search` route loses FTS ranking for\n * those entities (falls back to ILIKE), but search still works.\n *\n * `searchable({ fts: ... })` always requires at least one field, so\n * `config.fts` is truthy here implies `fields.length >= 1`. The check\n * is \"any non-translatable field\" — main `_search_tsv` exists iff at\n * least one of the `fts.fields` is non-translatable.\n */\nfunction hasNonTranslatableFtsField(entity: Entity, config: SearchableConfig): boolean {\n if (!config.fts) return false\n const allFields = entity.allFields as Record<string, FieldConfig | undefined>\n for (const fieldName of config.fts.fields) {\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig) continue\n if (!fieldConfig.translatable) return true\n }\n return false\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["resolveColumn"],"sources":["../src/ilike-provider.ts","../src/tsvector-provider.ts","../src/bootstrap.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","/**\n * Postgres tsvector / tsquery search provider.\n *\n * Backs `searchable({ fts: ... })` entities at the `@murumets-ee/search`\n * tier — `IlikeProvider` for substring-only, `TsvectorProvider` for FTS\n * with relevance ranking. ES still earns its keep at the commerce parts\n * catalog tier (per PLAN-ECOMMERCE D16); everything transactional flows\n * through Postgres so reads come from the source of truth.\n *\n * Implementation choices, grounded in research (CLAUDE.md §\"Drizzle support\"):\n *\n * - **`websearch_to_tsquery`** is the canonical user-input parser\n * (Drizzle's official FTS guide). Quoted phrases, `OR` operators,\n * leading `-` for negation — all handled natively, no client-side\n * parsing. Used for `term` and `phrase` modes.\n * - **`to_tsquery` + `:*` suffix** for `prefix` mode (typeahead). The\n * input is tokenised + escaped before composing the `& :*` form to\n * keep `to_tsquery` from throwing on malformed input.\n * - **`ts_rank_cd`** (cover density) ranking. Weighted matches (A > B)\n * naturally rank higher — the `setweight()` calls in the GENERATED\n * tsvector column do the heavy lifting; this provider just orders by\n * the resulting score.\n * - **`count(*) OVER ()`** window function for total. Single round-trip,\n * no separate count query, ~10-20% query overhead. Caveat: when\n * `offset >= total` the LIMIT/OFFSET drops every row so we have no\n * row to read the window count from — `total` is reported as `0` in\n * that contrived edge case. Real UIs don't paginate past the end; if\n * they do, the user gets a single misleading \"no results\" page until\n * they re-search.\n *\n * Single-entity mode only in this commit (#3). Cross-entity `includes`\n * JOIN composition lands in commit #5 via the UNION ALL + GROUP BY shape\n * specified in the plan.\n */\n\nimport type {\n SearchCapabilities,\n SearchInput,\n SearchProvider,\n SearchResult,\n SearchResultRow,\n} from '@murumets-ee/search'\nimport { escapeLikePattern } from '@murumets-ee/core'\nimport { SearchError, SearchErrorCodes } from '@murumets-ee/search'\nimport {\n and,\n asc,\n type Column,\n desc,\n eq,\n getTableColumns,\n ilike,\n inArray,\n or,\n type SQL,\n sql,\n} from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\nimport { unionAll } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\n/**\n * Minimal `AdminClient` shape the provider needs. Extends the\n * {@link IlikeProvider}'s shape with `getDb()` so we can drop down to\n * Drizzle's typed query builder for the FTS query (which `findMany`\n * cannot express because of `ts_rank_cd` / `@@` / window functions).\n *\n * `getTable()` returns the same dynamic-column shape as the public\n * `AdminClient.getTable()` — column refs are typed `any` from the\n * provider's perspective, which is the same trade-off the rest of the\n * search-postgres code makes.\n */\nexport interface TsvectorAdminClientLike {\n /**\n * The Drizzle table for the indexed entity. Must include the tsvector\n * column declared by `searchable({ fts: ... })` (default name\n * `_search_tsv`).\n */\n getTable(): PgTableLike\n /**\n * Drizzle database handle. Provider uses it to run the FTS SELECT\n * (which needs `ts_rank_cd` + window count — not expressible via\n * `findMany`).\n */\n getDb(): PostgresJsDatabase\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\n/** Default name for the GENERATED tsvector column produced by `searchable()`. */\nexport const DEFAULT_TSVECTOR_COLUMN = '_search_tsv'\n\n/** Default Postgres text-search configuration. `simple` is locale-neutral. */\nexport const DEFAULT_TSVECTOR_LANGUAGE = 'simple'\n\n/**\n * Internal column names used to smuggle the per-row score and the\n * window-function row count through the typed select. Namespaced with\n * `__lumi_` so they can't collide with real entity columns — both\n * `_score` and `_total` were too easy to overlap with user-defined\n * fields, and the `getTableColumns(...)`-spread pattern would silently\n * overwrite the real value with our synthetic one.\n */\nconst SCORE_KEY = '__lumi_score'\nconst TOTAL_KEY = '__lumi_total'\n\n/**\n * Cross-entity OR-search target. Declares that the provider should UNION\n * the included entity's tsvector hits into the parent's result set —\n * the canonical example is searching Ticket subjects AND TicketMessage\n * bodies and getting a single per-ticket result.\n *\n * The included entity MUST have its own `searchable({ fts: ... })`\n * declaration (so its `_search_tsv` column exists) and an\n * `AdminClient` provided via `TsvectorProviderConfig.includedClients`.\n */\nexport interface TsvectorInclude {\n /** Entity name of the related table (matches `defineEntity.name`). */\n entity: string\n /**\n * Foreign-key column on the included entity that points back to the\n * parent's `id`. Used as `parent_id` in the UNION subquery.\n */\n fk: string\n}\n\nexport interface TsvectorProviderConfig {\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`.\n */\n permissionResource?: string\n /**\n * The indexed entity's scope: `'global'` | `'team'` | `'user'`. Required\n * because the provider drops to raw Drizzle for FTS (`ts_rank_cd` +\n * window-count can't be expressed via `AdminClient.findMany`) and so\n * skips the scope-filter that `AdminClient` would otherwise add.\n *\n * v1 ONLY supports `'global'` — the constructor throws on anything else.\n * Scoped FTS needs a per-request `_scopeId` resolver wired into the\n * provider's WHERE clause; that work is a follow-up PR. Until then,\n * pair scoped entities with `searchable()` WITHOUT `fts` so they fall\n * through to `IlikeProvider`, which goes via `AdminClient.findMany` and\n * inherits scope filtering automatically.\n */\n entityScope?: 'global' | 'team' | 'user'\n /** AdminClient for the indexed entity. */\n client: TsvectorAdminClientLike\n /**\n * Postgres column name on the entity table that holds the tsvector.\n * Must exist; the constructor throws on a typo. Defaults to\n * `_search_tsv` — matches the column generated by\n * `searchable({ fts: ... })` in commit #4.\n */\n vectorColumn?: string\n /**\n * Postgres text-search configuration. Drives the GENERATED column\n * expression at write time AND the `websearch_to_tsquery` /\n * `to_tsquery` calls at read time — the two MUST match or rows won't\n * match their own indexed tokens. Defaults to `simple`.\n */\n language?: string\n /**\n * Whitelist of fields callers may filter on via `SearchInput.filters`.\n * Names outside the set are silently dropped — the only line of\n * defense against the documented \"arbitrary user-supplied field\n * name\" injection vector on `FacetFilters`.\n */\n filterableFields: readonly string[]\n /**\n * Cross-entity includes. Each included entity must have its own\n * `_search_tsv` column AND be exposed via `includedClients` so the\n * provider can resolve the table at search time.\n */\n includes?: readonly TsvectorInclude[]\n /**\n * AdminClients for each entity referenced in `includes`. Keyed by\n * entity name. Required when `includes` is non-empty.\n */\n includedClients?: Readonly<Record<string, TsvectorAdminClientLike>>\n /**\n * Default filters applied on the OUTER select — re-applied AFTER the\n * UNION so a body hit on a closed ticket does not leak through. The\n * inner per-table CTEs do NOT apply these filters (they're row-level\n * on the parent, not the included tables).\n */\n defaultFilters?: Readonly<Record<string, readonly string[]>>\n /**\n * Fields that should be OR-matched via ILIKE in addition to the\n * tsvector match. These are the declared `searchable().fields` minus\n * `searchable().fts.fields` — fields the consumer named as searchable\n * but which don't participate in the FTS ranking (e.g. Ticket's\n * `requesterEmail` / `requesterName` / `ticketNumber` when FTS is on\n * subject only). Without this, typing a sender email returns nothing\n * because the tsvector only covers subject.\n *\n * Each row matching ONLY via ILIKE (not via FTS) gets `score = 0`,\n * so FTS-ranked rows still surface first.\n */\n ilikeFields?: readonly string[]\n /**\n * Per D14, the provider does not synthesize a default projection.\n * Each consumer maps its rows into `SearchResultRow` explicitly so\n * `label` / `description` / `url` come from the right fields.\n */\n transform: (row: Record<string, unknown>) => SearchResultRow\n}\n\nconst TSVECTOR_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\n/**\n * Provider implementing FTS over a Postgres `tsvector` column.\n *\n * Wire it via `searchable({ fts: ... })` on the entity, then register\n * an instance in the admin `SearchRegistry` during plugin bootstrap\n * (commit #6).\n */\ninterface ResolvedInclude {\n entity: string\n fk: string\n client: TsvectorAdminClientLike\n}\n\nexport class TsvectorProvider implements SearchProvider {\n readonly resource: string\n readonly permissionResource?: string\n readonly capabilities = TSVECTOR_CAPABILITIES\n\n private readonly client: TsvectorAdminClientLike\n private readonly vectorColumn: string\n private readonly language: string\n private readonly filterableFieldSet: ReadonlySet<string>\n private readonly transform: (row: Record<string, unknown>) => SearchResultRow\n private readonly includes: readonly ResolvedInclude[]\n private readonly defaultFilters: Readonly<Record<string, readonly string[]>>\n private readonly ilikeFields: readonly string[]\n\n constructor(config: TsvectorProviderConfig) {\n const vectorColumn = config.vectorColumn ?? DEFAULT_TSVECTOR_COLUMN\n const language = config.language ?? DEFAULT_TSVECTOR_LANGUAGE\n\n // Refuse non-global scope: TsvectorProvider bypasses AdminClient's\n // built-in scope filter (raw Drizzle select for FTS), so a `'team'` /\n // `'user'` entity would silently leak cross-tenant rows through search.\n // See the `entityScope` field doc for the workaround.\n if (config.entityScope !== undefined && config.entityScope !== 'global') {\n throw new Error(\n `TsvectorProvider '${config.resource}' refuses entityScope='${config.entityScope}': v1 only supports 'global' (raw FTS query bypasses AdminClient scope-filter). Drop fts: {...} so searchable() falls through to IlikeProvider (which honors scope via AdminClient.findMany), or wait for scoped-FTS support.`,\n )\n }\n\n const table = config.client.getTable()\n if (resolveColumn(table, vectorColumn) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' tsvector column '${vectorColumn}' is not a column on the entity table`,\n )\n }\n for (const field of config.filterableFields) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' filterable field '${field}' is not a column on the entity table`,\n )\n }\n }\n // `defaultFilters` field names must resolve to real columns on the\n // entity table. A typo here is exactly the bug class `filterableFields`\n // already guards against — silently disabling a security/UX guardrail\n // (e.g. \"exclude closed tickets\") because `resolveColumn` returns\n // undefined and `buildDefaultFilterConditions` skips the unknown field.\n for (const field of Object.keys(config.defaultFilters ?? {})) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' defaultFilters field '${field}' is not a column on the entity table`,\n )\n }\n }\n // ILIKE-also fields (the non-fts subset of declared `searchable().fields`).\n // Validate they exist on the table so a typo throws here, not at first\n // search — same defensive pattern as `filterableFields`.\n for (const field of config.ilikeFields ?? []) {\n if (resolveColumn(table, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' ilikeFields entry '${field}' is not a column on the entity table`,\n )\n }\n }\n\n // Resolve includes: every included entity must (a) appear in\n // includedClients, (b) have the configured tsvector column, (c)\n // have the declared FK column. Failing fast here means we never\n // emit a UNION query with a column lookup that returns undefined.\n const includes: ResolvedInclude[] = []\n const rawIncludes = config.includes ?? []\n const includedClients = config.includedClients ?? {}\n for (const inc of rawIncludes) {\n const incClient = includedClients[inc.entity]\n if (!incClient) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' has no entry in includedClients`,\n )\n }\n const incTable = incClient.getTable()\n if (resolveColumn(incTable, vectorColumn) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' is missing tsvector column '${vectorColumn}' — it must have its own searchable({ fts: ... }) declaration`,\n )\n }\n if (resolveColumn(incTable, inc.fk) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' has no FK column '${inc.fk}'`,\n )\n }\n includes.push({ entity: inc.entity, fk: inc.fk, client: incClient })\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.vectorColumn = vectorColumn\n this.language = language\n this.filterableFieldSet = new Set(config.filterableFields)\n this.transform = config.transform\n this.includes = includes\n this.defaultFilters = config.defaultFilters ?? {}\n this.ilikeFields = config.ilikeFields ?? []\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 const result =\n this.includes.length === 0\n ? await this.searchSingleEntity(input)\n : await this.searchWithIncludes(input)\n if (signal.aborted) {\n throw new SearchError('Search aborted', 504, SearchErrorCodes.Timeout)\n }\n return result\n }\n\n /**\n * Single-entity FTS query. No JOIN, no UNION — just a `WHERE tsv @@ q`\n * scan over the main table with `ts_rank_cd` ordering and a window\n * count for `total`.\n */\n private async searchSingleEntity(input: SearchInput): Promise<SearchResult> {\n const table = this.client.getTable()\n const tsvCol = this.resolveTsvCol(table)\n const idCol = resolveColumn(table, 'id')\n if (idCol === undefined) {\n throw new Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`)\n }\n\n const tsQueryExpr = buildTsQuery(input.query, input.mode, this.language)\n const rankExpr = sql<number>`ts_rank_cd(${tsvCol}, ${tsQueryExpr})`\n // Match expression: FTS tsvector match OR ILIKE on non-fts declared\n // fields. Without the ILIKE OR, fields like Ticket.requesterEmail\n // (declared as searchable but not in fts.fields) are unreachable —\n // typing \"ranko\" returns nothing even when ranko@... is a requester.\n const matchExpr = this.buildMatchExpr(table, tsvCol, tsQueryExpr, input.query)\n\n const filterConditions = this.buildAllFilterConditions(input.filters, table)\n const whereExpr =\n filterConditions.length === 0 ? matchExpr : and(matchExpr, ...filterConditions)\n if (whereExpr === undefined) {\n throw new Error('unreachable: and() called with at least one arg')\n }\n\n const drizzleTable = table as unknown as PgTable\n const start = Date.now()\n const rows = await this.client\n .getDb()\n .select({\n ...getTableColumns(drizzleTable),\n [SCORE_KEY]: rankExpr,\n [TOTAL_KEY]: sql<number>`count(*) OVER ()::int`,\n })\n .from(drizzleTable)\n .where(whereExpr)\n .orderBy(desc(rankExpr), asc(idCol))\n .limit(input.limit)\n .offset(input.offset)\n\n const durationMs = Date.now() - start\n return this.shapeResult(rows, durationMs)\n }\n\n /**\n * Cross-entity FTS via UNION ALL + GROUP BY hits subquery. Each\n * configured include contributes its own per-parent rank set; the\n * outer GROUP BY collapses duplicates and keeps the highest score\n * per parent.\n *\n * Default filters are re-applied on the outer SELECT — a body hit on\n * an archived ticket would otherwise leak through because the per-\n * include CTE only sees the related table's columns.\n */\n private async searchWithIncludes(input: SearchInput): Promise<SearchResult> {\n const mainTable = this.client.getTable()\n const mainTsvCol = this.resolveTsvCol(mainTable)\n const mainIdCol = resolveColumn(mainTable, 'id')\n if (mainIdCol === undefined) {\n throw new Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`)\n }\n\n const tsQueryExpr = buildTsQuery(input.query, input.mode, this.language)\n\n // Per-table hit selects, all aliased into a uniform\n // (parent_id, score) shape so unionAll types align.\n //\n // Main-table WHERE: FTS match OR ILIKE on non-fts fields. Without\n // the ILIKE OR, Ticket-side searches by requesterEmail / name /\n // ticketNumber would miss because only subject is in the tsvector.\n const mainSelect = this.client\n .getDb()\n .select({\n parent_id: sql<string>`${mainIdCol}`.as('parent_id'),\n score: sql<number>`ts_rank_cd(${mainTsvCol}, ${tsQueryExpr})`.as('score'),\n })\n .from(mainTable as unknown as PgTable)\n .where(this.buildMatchExpr(mainTable, mainTsvCol, tsQueryExpr, input.query))\n\n const includeSelects = this.includes.map((inc) => {\n const incTable = inc.client.getTable()\n const incTsvCol = this.resolveTsvCol(incTable)\n const fkCol = resolveColumn(incTable, inc.fk)\n if (fkCol === undefined) {\n throw new Error(\n `TsvectorProvider '${this.resource}' include '${inc.entity}' lost FK column '${inc.fk}' between construction and search`,\n )\n }\n // Use the included entity's OWN db handle for the per-table SELECT\n // builder. NOTE: at execute time Drizzle's `unionAll(left, right)`\n // composes both into the LEFT select's connection — the right\n // side's bound db is ignored. So this is a single-connection query\n // in practice. The `inc.client.getDb()` call is kept as a\n // documentation signal: each include's per-table query is logically\n // scoped to its own entity's client, even though the SQL is\n // executed via the parent's connection. If a future setup wires\n // cross-connection UNION (separate queries + in-memory merge),\n // this is the read path that already names the right connection.\n return inc.client\n .getDb()\n .select({\n parent_id: sql<string>`${fkCol}`.as('parent_id'),\n score: sql<number>`MAX(ts_rank_cd(${incTsvCol}, ${tsQueryExpr}))`.as('score'),\n })\n .from(incTable as unknown as PgTable)\n .where(sql`${incTsvCol} @@ ${tsQueryExpr}`)\n .groupBy(sql`${fkCol}`)\n })\n\n // Drizzle's `unionAll(leftSelect, rightSelect, ...rest)` is variadic\n // but its overload signature is awkward to type when `rest` comes\n // from a `.map()`. Splitting into head + tail removes the need for\n // an `any` cast — `includes.length > 0` is enforced by the\n // dispatch in `search()`, so `includeSelects[0]` is always defined.\n const firstInclude = includeSelects[0]\n if (!firstInclude) {\n // Unreachable: searchWithIncludes is only called when includes.length > 0.\n throw new Error(\n `TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`,\n )\n }\n const hitsUnion = unionAll(mainSelect, firstInclude, ...includeSelects.slice(1)).as(\n 'union_hits',\n )\n\n // Outer dedupe: collapse per parent_id, keep max score.\n const dedupedSubquery = this.client\n .getDb()\n .select({\n parent_id: sql<string>`${hitsUnion.parent_id}`.as('parent_id'),\n score: sql<number>`MAX(${hitsUnion.score})`.as('score'),\n })\n .from(hitsUnion)\n .groupBy(hitsUnion.parent_id)\n .as('h')\n\n // Re-apply default + user filters on the outer SELECT so an\n // include-only hit still respects per-parent constraints.\n const outerFilters = this.buildAllFilterConditions(input.filters, mainTable)\n const drizzleMain = mainTable as unknown as PgTable\n const start = Date.now()\n\n const baseSelect = this.client\n .getDb()\n .select({\n ...getTableColumns(drizzleMain),\n [SCORE_KEY]: sql<number>`${dedupedSubquery.score}`,\n // `count(*) OVER ()` returns int8; cast to int4 so postgres-js\n // returns a JS number (default OID parsers cover int4/int2/oid/\n // float4/float8 but NOT int8). Without `::int` postgres-js leaves\n // it as a string and `typeof total === 'number'` is false →\n // every page returns total=0.\n [TOTAL_KEY]: sql<number>`count(*) OVER ()::int`,\n })\n .from(dedupedSubquery)\n .innerJoin(drizzleMain, eq(dedupedSubquery.parent_id, mainIdCol))\n\n const ordered = (\n outerFilters.length === 0 ? baseSelect : baseSelect.where(and(...outerFilters))\n )\n .orderBy(desc(dedupedSubquery.score), asc(mainIdCol))\n .limit(input.limit)\n .offset(input.offset)\n\n const rows = await ordered\n const durationMs = Date.now() - start\n return this.shapeResult(rows, durationMs)\n }\n\n private resolveTsvCol(table: PgTableLike): Column {\n const col = resolveColumn(table, this.vectorColumn)\n if (col === undefined) {\n throw new Error(\n `TsvectorProvider '${this.resource}' tsvector column '${this.vectorColumn}' missing at search time`,\n )\n }\n return col\n }\n\n /**\n * Compose the row-match expression: tsvector match OR ILIKE on each\n * configured `ilikeFields` entry. The user-typed query is escaped\n * with `escapeLikePattern` before the `%...%` wrap so LIKE wildcards\n * in input don't widen the match.\n *\n * Rows matching ONLY via ILIKE get `score = 0` from `ts_rank_cd`\n * (FTS didn't match, so ranking is genuinely zero). FTS-ranked rows\n * still surface first under `ORDER BY score DESC`.\n */\n private buildMatchExpr(\n table: PgTableLike,\n tsvCol: Column,\n tsQueryExpr: SQL,\n rawQuery: string,\n ): SQL {\n const tsMatch = sql`${tsvCol} @@ ${tsQueryExpr}`\n if (this.ilikeFields.length === 0) return tsMatch\n const pattern = `%${escapeLikePattern(rawQuery)}%`\n const ilikePieces: SQL[] = []\n for (const fieldName of this.ilikeFields) {\n const column = resolveColumn(table, fieldName)\n if (column === undefined) continue\n ilikePieces.push(ilike(column, pattern))\n }\n if (ilikePieces.length === 0) return tsMatch\n const firstIlike = ilikePieces[0]\n if (!firstIlike) return tsMatch\n const combinedIlike =\n ilikePieces.length === 1 ? firstIlike : or(firstIlike, ...ilikePieces.slice(1))\n if (!combinedIlike) return tsMatch\n const combined = or(tsMatch, combinedIlike)\n return combined ?? tsMatch\n }\n\n private shapeResult(rows: Record<string, unknown>[], durationMs: number): SearchResult {\n const total = readTotal(rows)\n const resultRows = rows.map((row) => {\n const projected = this.transform(stripWindowColumns(row))\n const score = pickScore(row)\n return score !== undefined ? { ...projected, score } : projected\n })\n return { rows: resultRows, total, facets: [], durationMs }\n }\n\n /**\n * Compose the full filter set: user-supplied filters from\n * `SearchInput.filters` (narrowed to `filterableFields`) plus the\n * provider's own `defaultFilters`. Per-key REPLACE semantics — if the\n * caller passes any value for a key that has a default, the user's\n * value wins for that key and the default is dropped. Defaults still\n * apply to every key the user did NOT mention. This matches the\n * Zendesk-style \"search active tickets by default, but `?f.status=\n * closed` surfaces closed tickets\" UX. AND-ing both sets (the v1\n * behavior) made user filters incapable of broadening the live set,\n * which broke the documented \"surface closed tickets\" path.\n */\n private buildAllFilterConditions(\n inputFilters: SearchInput['filters'],\n table: PgTableLike,\n ): SQL[] {\n const out: SQL[] = []\n out.push(...this.buildFilterConditions(inputFilters, table))\n const userFilterKeys = new Set(Object.keys(inputFilters))\n for (const [field, values] of Object.entries(this.defaultFilters)) {\n if (userFilterKeys.has(field)) continue\n const condition = this.buildSingleFilterCondition(field, values, table)\n if (condition !== undefined) out.push(condition)\n }\n return out\n }\n\n /**\n * Compile a single field/values pair into the `eq` or `inArray`\n * condition the WHERE clause expects. Returns `undefined` when the\n * column does not resolve or `values` is empty — callers skip in that\n * case rather than emit a degenerate `WHERE col IN ()`.\n */\n private buildSingleFilterCondition(\n field: string,\n values: readonly string[],\n table: PgTableLike,\n ): SQL | undefined {\n const column = resolveColumn(table, field)\n if (column === undefined) return undefined\n if (values.length === 0) return undefined\n if (values.length === 1) {\n const single = values[0]\n if (single === undefined) return undefined\n return eq(column, single)\n }\n return inArray(column, [...values])\n }\n\n /**\n * Compile `filters` into AND-combined `eq` / `inArray` conditions.\n * Field names outside `filterableFields` are silently dropped — the\n * defense against the FacetFilters injection vector.\n */\n private buildFilterConditions(filters: SearchInput['filters'], table: PgTableLike): SQL[] {\n const out: SQL[] = []\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 return out\n }\n}\n\n/**\n * Build the per-mode tsquery expression.\n *\n * The three modes serve distinct UX shapes:\n *\n * - `term` → `websearch_to_tsquery` — handles quoted phrases, OR /\n * negation operators, raw user input. Postgres-native parser,\n * never throws on malformed input.\n * - `prefix` → `to_tsquery(...)` with each token suffixed `:*` so\n * typeahead matches partial words. Special-character\n * escaping required because `to_tsquery` IS strict; we\n * strip tsquery operators from each token before joining.\n * - `phrase` → `phraseto_tsquery` — strict word-adjacency match. Less\n * permissive than websearch but matches the documented\n * `phrase` mode semantics in `@murumets-ee/search`.\n *\n * @internal exported for the unit test that asserts compiled SQL per mode.\n */\nexport function buildTsQuery(query: string, mode: SearchInput['mode'], language: string): SQL {\n switch (mode) {\n case 'prefix': {\n // Tokenize on whitespace, escape tsquery operators + special-form\n // characters from each token, suffix `:*`, AND them together.\n // The full strip-set:\n // & | ! — tsquery boolean operators\n // ( ) — grouping\n // : * — weight / prefix-marker (replaced with `:*` ourselves)\n // < > — distance operators (`<-> ` etc.)\n // ? — tsquery wildcard\n // \\ — escape character (causes to_tsquery to throw on\n // trailing backslash; values are bind-parameterized\n // so SQL injection is impossible, but a 500 is\n // worse UX than a no-match)\n // ' \" — unbalanced quotes break the parser the same way\n // ; — defense-in-depth (parser already tolerates it)\n const tokens = query\n .split(/\\s+/)\n .map((t) => t.replace(/[&|!():*<>?\\\\'\";]/g, '').trim())\n .filter((t) => t.length > 0)\n if (tokens.length === 0) {\n // tsquery 'null' (literal token unlikely to match) keeps the\n // semantics of \"no match\" without the `to_tsquery` parser\n // throwing on an empty string.\n return sql`to_tsquery(${language}, ${'__no_match__:*'})`\n }\n const tsqueryText = tokens.map((t) => `${t}:*`).join(' & ')\n return sql`to_tsquery(${language}, ${tsqueryText})`\n }\n case 'phrase':\n return sql`phraseto_tsquery(${language}, ${query})`\n default:\n return sql`websearch_to_tsquery(${language}, ${query})`\n }\n}\n\n/**\n * Extract the window-count total from the result rows. Returns `0` when\n * the result set is empty — see the JSDoc edge case at the top of file.\n *\n * `Number()` coerces strings — defensive: `count(*) OVER ()::int` should\n * already arrive as a number from postgres-js (int4 has a default\n * parser), but if anyone removes the `::int` cast in the future they\n * get a degraded total rather than total=0.\n */\nfunction readTotal(rows: ReadonlyArray<Record<string, unknown>>): number {\n if (rows.length === 0) return 0\n const first = rows[0]\n if (!first) return 0\n const total = first[TOTAL_KEY]\n if (typeof total === 'number') return total\n if (typeof total === 'string') {\n const parsed = Number(total)\n return Number.isFinite(parsed) ? parsed : 0\n }\n return 0\n}\n\n/**\n * Strip the synthetic per-row score and total columns before passing\n * the row to the user-supplied `transform()`. Namespaced keys avoid\n * collisions with real entity columns (see `SCORE_KEY` / `TOTAL_KEY`).\n */\nfunction stripWindowColumns(row: Record<string, unknown>): Record<string, unknown> {\n const rest: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(row)) {\n if (key === SCORE_KEY || key === TOTAL_KEY) continue\n rest[key] = value\n }\n return rest\n}\n\n/** Read the per-row `ts_rank_cd` score for the SearchResultRow `score` field. */\nfunction pickScore(row: Record<string, unknown>): number | undefined {\n const value = row[SCORE_KEY]\n if (typeof value === 'number') return value\n if (typeof value === 'string') {\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n }\n return undefined\n}\n","/**\n * Admin search bootstrap — turn a list of entities into a `SearchRegistry`\n * populated with one provider per `searchable()`-declaring entity.\n *\n * Consumer pattern (one call in the admin API handler setup):\n *\n * ```ts\n * import { buildSearchRegistry } from '@murumets-ee/search-postgres'\n * import { searchRoutes, proxyClientIp } from '@murumets-ee/search/admin'\n *\n * const searchRegistry = buildSearchRegistry({ entities: crudEntities })\n *\n * const routes = [\n * ...\n * searchRoutes({ registry: searchRegistry, getClientIp: proxyClientIp }),\n * ]\n * ```\n *\n * Bootstrap is split into two passes so cross-entity `includes` references\n * can resolve to other entities' `AdminClient` instances regardless of\n * declaration order:\n *\n * 1. Build the `AdminClient` for every entity declaring `searchable()`.\n * 2. Build provider instances — for entities with `fts`, attach\n * `includedClients` resolved from pass 1. For entities without `fts`,\n * fall back to `IlikeProvider` (substring match across declared fields).\n *\n * Cross-entity entities are rejected when:\n * - the included entity does not declare `searchable()`\n * - the included entity has no `fts` (no `_search_tsv` column → no\n * way to UNION its hits)\n *\n * Both cases throw with a clear message at bootstrap time. The pattern\n * is \"fail loud\" — silent misconfigurations would produce empty search\n * results, which is worse than a startup error.\n */\n\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport {\n type Entity,\n getSearchableConfig,\n type SearchableConfig,\n type SearchableResultRow,\n} from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { type SearchProvider, SearchRegistry, type SearchResultRow } from '@murumets-ee/search'\nimport { type AdminClientLike, IlikeProvider } from './ilike-provider'\nimport { type TsvectorAdminClientLike, TsvectorProvider } from './tsvector-provider'\n\nexport interface BuildSearchRegistryOptions {\n /**\n * All entities the admin UI exposes. Entities without a `searchable()`\n * behavior are skipped (no provider registered → admin search route\n * returns 404 for that resource, EntityList toolbar disables the\n * search input).\n */\n entities: readonly Entity[]\n /**\n * Additional pre-built providers to register alongside the entity-\n * derived ones. Use this for non-`defineEntity` resources — the\n * canonical case is commerce parts catalog (Elasticsearch-backed,\n * registered under its own resource name).\n */\n extraProviders?: readonly SearchProvider[]\n /**\n * Test seam — override `createAdminClient`. Defaults to the production\n * factory, which reads the current `ToolkitApp` from AsyncLocalStorage.\n */\n // biome-ignore lint/suspicious/noExplicitAny: caller-defined entity field shapes — test seam only\n createClient?: (entity: Entity<any>) => AdminClient<any>\n}\n\n/**\n * Project an entity row → `SearchResultRow`. The provider config types\n * `transform` as `(row) => SearchResultRow`; the searchable() config\n * holds `(row) => SearchableResultRow`. The two types are structurally\n * identical (designed that way, see entity-package note in\n * `searchable.ts`) — this adapter just narrows the variance so providers\n * see the right type.\n */\nfunction adaptProjection(\n projection: (row: Record<string, unknown>) => SearchableResultRow,\n): (row: Record<string, unknown>) => SearchResultRow {\n return (row) => projection(row)\n}\n\n/**\n * Build a `SearchRegistry` populated with one provider per\n * `searchable()`-declaring entity.\n *\n * Pass 1 builds `AdminClient`s for every searchable entity. Pass 2\n * constructs provider instances, resolving cross-entity `includes`\n * against the pass-1 map. Both passes throw on misconfiguration —\n * silent gaps are not acceptable for an admin search system.\n */\nexport function buildSearchRegistry(options: BuildSearchRegistryOptions): SearchRegistry {\n const registry = new SearchRegistry()\n const createClient = options.createClient ?? createAdminClient\n\n // Pass 1 — build clients for every entity that opts in.\n const searchableEntities = new Map<\n string,\n {\n entity: Entity\n config: SearchableConfig\n client: AdminClient\n }\n >()\n\n for (const entity of options.entities) {\n const config = getSearchableConfig(entity)\n if (!config) continue\n const client = createClient(entity) as AdminClient\n searchableEntities.set(entity.name, { entity, config, client })\n }\n\n // Pass 2 — build providers, resolving includes against the pass-1 map.\n for (const { entity, config, client } of searchableEntities.values()) {\n // Translatable-only fts entities (e.g. Article with title/excerpt/body\n // all translatable) have their `_search_tsv` column on the\n // `<entity>_translations` table, NOT the main table. TsvectorProvider's\n // constructor checks the main table and would throw. v1 does not yet\n // support translation-table FTS via the dedicated route — fall back\n // to IlikeProvider so search still works (substring across declared\n // `fields` AND translatable fields' translation rows are reachable\n // via the existing translation table joins). When translation-table\n // FTS lands, this branch goes away.\n const provider =\n config.fts && hasNonTranslatableFtsField(entity, config)\n ? buildTsvectorProvider(entity, config, client, searchableEntities)\n : buildIlikeProvider(entity, config, client)\n registry.register(provider)\n }\n\n // Register extras (commerce parts ES, etc.) after entity-derived\n // providers. `SearchRegistry.register()` throws on resource-name\n // collision — intentional fail-loud, NOT \"extras shadow entities\".\n // If an extra and an entity-derived provider share a resource name,\n // boot fails with a clear duplicate-registration error; resolve by\n // renaming the resource on one side.\n for (const extra of options.extraProviders ?? []) {\n registry.register(extra)\n }\n\n return registry\n}\n\n/**\n * Build an `IlikeProvider` from an entity's `searchable({ fields })`\n * declaration. Used when no `fts` is configured — substring match across\n * declared fields, no ranking.\n */\nfunction buildIlikeProvider(\n entity: Entity,\n config: SearchableConfig,\n client: AdminClient,\n): SearchProvider {\n // `IlikeProvider` expects `AdminClientLike<TRow>`. The AdminClient\n // shape extends it; the cast is structural and safe.\n const adminLike = client as unknown as AdminClientLike<Record<string, unknown>>\n return new IlikeProvider<Record<string, unknown>>({\n resource: entity.name,\n client: adminLike,\n searchableFields: [...config.fields],\n transform: adaptProjection(config.projection),\n })\n}\n\n/**\n * Build a `TsvectorProvider` from an entity's `searchable({ fts, includes? })`\n * declaration. Cross-entity `includes` are resolved to their AdminClients\n * — every included entity must itself declare `searchable({ fts: ... })`\n * so its `_search_tsv` column exists.\n */\nfunction buildTsvectorProvider(\n entity: Entity,\n config: SearchableConfig,\n client: AdminClient,\n searchableEntities: Map<\n string,\n {\n entity: Entity\n config: SearchableConfig\n client: AdminClient\n }\n >,\n): SearchProvider {\n const includedClients: Record<string, TsvectorAdminClientLike> = {}\n for (const inc of config.includes ?? []) {\n const target = searchableEntities.get(inc.entity)\n if (!target) {\n throw new Error(\n `searchable() on entity \"${entity.name}\" includes \"${inc.entity}\", but \"${inc.entity}\" has no searchable() declaration — add one, including fts: {...} so its _search_tsv column exists`,\n )\n }\n if (!target.config.fts) {\n throw new Error(\n `searchable() on entity \"${entity.name}\" includes \"${inc.entity}\", but \"${inc.entity}\" has searchable() WITHOUT fts: {...} — UNION joins require the included entity to have an indexed tsvector column`,\n )\n }\n includedClients[inc.entity] = target.client as unknown as TsvectorAdminClientLike\n }\n\n // Build the provider config piece by piece so optional fields don't\n // smuggle `undefined` past `exactOptionalPropertyTypes`.\n //\n // `ilikeFields` carries the searchable fields that DON'T contribute\n // to the tsvector — the provider OR-ILIKE's them at search time so\n // typing \"ranko\" on Ticket (fts on subject only) still matches\n // requesterEmail / requesterName / ticketNumber.\n const ftsFieldSet = config.fts ? new Set(config.fts.fields) : new Set<string>()\n const ilikeFields = config.fields.filter((f) => !ftsFieldSet.has(f))\n const providerConfig: ConstructorParameters<typeof TsvectorProvider>[0] = {\n resource: entity.name,\n client: client as unknown as TsvectorAdminClientLike,\n filterableFields: [...config.fields],\n ilikeFields,\n transform: adaptProjection(config.projection),\n }\n // Forward the entity's scope so TsvectorProvider can refuse non-global\n // scoped entities at construction. v1 doesn't have a scoped-FTS path;\n // an unguarded scoped entity would silently leak cross-tenant rows.\n if (entity.scope !== undefined) {\n providerConfig.entityScope = entity.scope\n }\n if (config.fts?.language !== undefined) {\n providerConfig.language = config.fts.language\n }\n if (config.includes && config.includes.length > 0) {\n providerConfig.includes = config.includes.map((inc) => ({\n entity: inc.entity,\n fk: inc.fk,\n }))\n providerConfig.includedClients = includedClients\n }\n if (config.defaultFilters !== undefined) {\n providerConfig.defaultFilters = config.defaultFilters\n }\n return new TsvectorProvider(providerConfig)\n}\n\n/**\n * Whether the entity has at least one `fts.field` that lives on the main\n * table (i.e. NOT translatable). Determines provider choice: when every\n * `fts.field` is translatable the main table has no `_search_tsv`, so\n * `TsvectorProvider`'s constructor — which checks the main table — would\n * throw. The dedicated `/api/admin/search` route loses FTS ranking for\n * those entities (falls back to ILIKE), but search still works.\n *\n * `searchable({ fts: ... })` always requires at least one field, so\n * `config.fts` is truthy here implies `fields.length >= 1`. The check\n * is \"any non-translatable field\" — main `_search_tsv` exists iff at\n * least one of the `fts.fields` is non-translatable.\n */\nfunction hasNonTranslatableFtsField(entity: Entity, config: SearchableConfig): boolean {\n if (!config.fts) return false\n const allFields = entity.allFields as Record<string, FieldConfig | undefined>\n for (const fieldName of config.fts.fields) {\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig) continue\n if (!fieldConfig.translatable) return true\n }\n return false\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/search-postgres",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
],
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"drizzle-orm": "^0.45.2",
|
|
17
|
-
"@murumets-ee/core": "0.16.
|
|
18
|
-
"@murumets-ee/entity": "0.16.
|
|
19
|
-
"@murumets-ee/search": "0.16.
|
|
17
|
+
"@murumets-ee/core": "0.16.2",
|
|
18
|
+
"@murumets-ee/entity": "0.16.2",
|
|
19
|
+
"@murumets-ee/search": "0.16.2"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^20.19.40",
|