@murumets-ee/search-postgres 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/canonical-url.ts","../src/bootstrap.ts","../src/ilike-provider.ts","../src/tsvector-provider.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/canonical-url.ts","../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{buildEntityHref as t,buildTranslationsSearchExists as n,filterTranslatableFields as r,getSearchableConfig as i}from"@murumets-ee/entity";import{escapeLikePattern as a}from"@murumets-ee/core";import{SearchError as o,SearchErrorCodes as s}from"@murumets-ee/search";import{and as c,asc as l,desc as u,eq as d,getTableColumns as f,ilike as p,inArray as m,isNull as h,or as g,sql as _}from"drizzle-orm";import{unionAll as v}from"drizzle-orm/pg-core";function y(e,t,n,r){return{resource:e.resource,capabilities:e.capabilities,...e.permissionResource===void 0?{}:{permissionResource:e.permissionResource},async search(i,a){let o=await e.search(i,a),s=i.locale??r;if(s===void 0)return o;let c=o.rows.filter(e=>e.url===void 0).map(e=>e.id);if(c.length===0)return o;let l=await n(t,c,s);return l.size===0?o:{...o,rows:o.rows.map(e=>{if(e.url!==void 0)return e;let t=l.get(e.id);return t===void 0?e:{...e,url:t}})}}}}function b(e,t){return e[t]}const x={fullText:!1,ranking:!1,facets:!1,fuzzy:!1,prefix:!0};var S=class{resource;permissionResource;capabilities=x;client;searchableFields;filterableFieldSet;translatableFields;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(b(t,r)===void 0)throw Error(`IlikeProvider '${e.resource}' field '${r}' is not a column on the entity table`);let r=e.translatableFields??[];if(r.length>0){let t=e.client.getTranslationsTable?.()??null;if(t===null)throw Error(`IlikeProvider '${e.resource}' declares translatableFields but the entity has no translations table`);for(let n of r)if(b(t,n)===void 0)throw Error(`IlikeProvider '${e.resource}' translatable field '${n}' is not a column on the translations 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.translatableFields=r,this.transform=e.transform}async search(e,t){if(t.aborted)throw new o(`Search aborted`,504,s.Timeout);let n=this.buildQueryCondition(e);if(n===null)return{rows:[],total:0,facets:[],durationMs:0};let r=c(n,...this.buildFilterConditions(e.filters));if(r===void 0)throw Error(`unreachable: and() called with at least one arg`);let i=r,a={where:i,limit:e.limit,offset:e.offset};e.locale!==void 0&&(a.locale=e.locale);let l=Date.now(),u=await this.client.findMany(a);if(t.aborted)throw new o(`Search aborted`,504,s.Timeout);let d=Date.now()-l,f=await this.client.count({where:i});if(t.aborted)throw new o(`Search aborted`,504,s.Timeout);return{rows:u.map(t=>this.transform(t,e.locale)),total:f,facets:[],durationMs:d}}buildQueryCondition(e){let t=this.client.getTable(),n=[],r=C(e.query,e.mode);for(let i of this.searchableFields){let a=b(t,i);a!==void 0&&(e.mode===`term`?n.push(d(a,e.query)):n.push(p(a,r)))}let i=this.buildTranslationsCondition(t,e,r);if(i!==void 0&&n.push(i),n.length===0)return null;let a=n[0];if(a===void 0)return null;if(n.length===1)return a;let o=g(...n);if(o===void 0)throw Error(`unreachable: or() called with ≥ 2 args`);return o}buildTranslationsCondition(e,t,r){if(this.translatableFields.length===0)return;let i=this.client.getTranslationsTable?.()??null;if(i===null)return;let a=b(e,`id`);if(a===void 0)return;let o={idColumn:a,translationsTable:i,fields:this.translatableFields,buildCondition:e=>t.mode===`term`?d(e,t.query):p(e,r)};return t.locale!==void 0&&(o.locale=t.locale),n(o)}buildFilterConditions(e){let t=this.client.getTable(),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(d(e,t))}else n.push(m(e,[...i]))}return n}};function C(e,t){let n=a(e);return t===`prefix`?`${n}%`:`%${n}%`}function w(e,t){return e[t]}const T=`_search_tsv`,E=`simple`,D=`__lumi_score`,O=`__lumi_total`,k={fullText:!0,ranking:!0,facets:!1,fuzzy:!1,prefix:!0};var A=class{resource;permissionResource;capabilities=k;client;vectorColumn;language;filterableFieldSet;transform;includes;defaultFilters;ilikeFields;translatableFields;translationOverlayFields;defaultLocale;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(w(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(w(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(w(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(w(r,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' ilikeFields entry '${t}' is not a column on the entity table`);let i=e.translatableFields??[],a=e.translationOverlayFields??[];if(i.length>0||a.length>0){let t=e.client.getTranslationsTable?.()??null;if(t===null)throw Error(`TsvectorProvider '${e.resource}' declares translatable fields but the entity has no translations table`);for(let n of[...i,...a])if(w(t,n)===void 0)throw Error(`TsvectorProvider '${e.resource}' translatable field '${n}' is not a column on the translations table`)}let o=[],s=e.includes??[],c=e.includedClients??{};for(let n of s){let r=c[n.entity];if(!r)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no entry in includedClients`);let i=r.getTable();if(w(i,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(w(i,n.fk)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${n.entity}' has no FK column '${n.fk}'`);o.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=o,this.defaultFilters=e.defaultFilters??{},this.ilikeFields=e.ilikeFields??[],this.translatableFields=i,this.translationOverlayFields=a,this.defaultLocale=e.defaultLocale}async search(e,t){if(t.aborted)throw new o(`Search aborted`,504,s.Timeout);let n=this.includes.length===0?await this.searchSingleEntity(e):await this.searchWithIncludes(e);if(t.aborted)throw new o(`Search aborted`,504,s.Timeout);return n}async searchSingleEntity(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=w(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=j(e.query,e.mode,this.language),a=_`ts_rank_cd(${n}, ${i})`,o=this.buildMatchExpr(t,n,i,e.query,e.locale),s=this.buildAllFilterConditions(e.filters,t),d=s.length===0?o:c(o,...s);if(d===void 0)throw Error(`unreachable: and() called with at least one arg`);let p=t,m=Date.now(),h=await this.client.getDb().select({...f(p),[D]:a,[O]:_`count(*) OVER ()::int`}).from(p).where(d).orderBy(u(a),l(r)).limit(e.limit).offset(e.offset),g=await this.overlayTranslations(h,e.locale),v=Date.now()-m;return this.shapeResult(g,v,e.locale)}async searchWithIncludes(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=w(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=j(e.query,e.mode,this.language),a=this.client.getDb().select({parent_id:_`${r}`.as(`parent_id`),score:_`ts_rank_cd(${n}, ${i})`.as(`score`)}).from(t).where(this.buildMatchExpr(t,n,i,e.query,e.locale)),o=this.includes.map(e=>{let t=e.client.getTable(),n=this.resolveTsvCol(t),r=w(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:_`${r}`.as(`parent_id`),score:_`MAX(ts_rank_cd(${n}, ${i}))`.as(`score`)}).from(t).where((()=>{let e=_`${n} @@ ${i}`,r=this.buildTrashFilter(t);return r?c(e,r)??e:e})()).groupBy(_`${r}`)}),s=o[0];if(!s)throw Error(`TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`);let p=v(a,s,...o.slice(1)).as(`union_hits`),m=this.client.getDb().select({parent_id:_`${p.parent_id}`.as(`parent_id`),score:_`MAX(${p.score})`.as(`score`)}).from(p).groupBy(p.parent_id).as(`h`),h=this.buildAllFilterConditions(e.filters,t),g=t,y=Date.now(),b=this.client.getDb().select({...f(g),[D]:_`${m.score}`,[O]:_`count(*) OVER ()::int`}).from(m).innerJoin(g,d(m.parent_id,r)),x=await(h.length===0?b:b.where(c(...h))).orderBy(u(m.score),l(r)).limit(e.limit).offset(e.offset),S=await this.overlayTranslations(x,e.locale),C=Date.now()-y;return this.shapeResult(S,C,e.locale)}resolveTsvCol(e){let t=w(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,i){let o=_`${t} @@ ${n}`,s=`%${a(r)}%`,c=[o];for(let t of this.ilikeFields){let n=w(e,t);n!==void 0&&c.push(p(n,s))}let l=this.buildTranslationsCondition(e,s,i);return l!==void 0&&c.push(l),c.length===1?o:g(...c)??o}buildTranslationsCondition(e,t,r){if(this.translatableFields.length===0)return;let i=this.client.getTranslationsTable?.()??null;if(i===null)return;let a=w(e,`id`);if(a===void 0)return;let o={idColumn:a,translationsTable:i,fields:this.translatableFields,buildCondition:e=>p(e,t)};return r!==void 0&&(o.locale=r),n(o)}async overlayTranslations(e,t){if(t===void 0||e.length===0||t===this.defaultLocale||this.translationOverlayFields.length===0)return e;let n=this.client.getTranslationsTable?.()??null;if(n===null)return e;let r=w(n,`entityId`),i=w(n,`locale`);if(r===void 0||i===void 0)return e;let a=e.map(e=>e.id).filter(e=>typeof e==`string`);if(a.length===0)return e;let o=await this.client.getDb().select().from(n).where(c(m(r,a),d(i,t))),s=new Map;for(let e of o){let t={};for(let n of this.translationOverlayFields){let r=e[n];r!=null&&(t[n]=r)}s.set(e.entityId,t)}return e.map(e=>{let t=s.get(e.id);return t?{...e,...t}:e})}shapeResult(e,t,n){let r=M(e);return{rows:e.map(e=>{let t=this.transform(N(e),n),r=P(e);return r===void 0?t:{...t,score:r}}),total:r,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)}let i=this.buildTrashFilter(t);return i&&n.push(i),n}buildTrashFilter(e){let t=w(e,`deletedAt`);return t?h(t):void 0}buildSingleFilterCondition(e,t,n){let r=w(n,e);if(r!==void 0&&t.length!==0){if(t.length===1){let e=t[0];return e===void 0?void 0:d(r,e)}return m(r,[...t])}}buildFilterConditions(e,t){let n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=w(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(d(e,t))}else n.push(m(e,[...i]))}return n}};function j(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?_`to_tsquery(${n}, ${`__no_match__:*`})`:_`to_tsquery(${n}, ${t.map(e=>`${e}:*`).join(` & `)})`}case`phrase`:return _`phraseto_tsquery(${n}, ${e})`;default:return _`websearch_to_tsquery(${n}, ${e})`}}function M(e){if(e.length===0)return 0;let t=e[0];if(!t)return 0;let n=t[O];if(typeof n==`number`)return n;if(typeof n==`string`){let e=Number(n);return Number.isFinite(e)?e:0}return 0}function N(e){let t={};for(let[n,r]of Object.entries(e))n===D||n===O||(t[n]=r);return t}function P(e){let t=e[D];if(typeof t==`number`)return t;if(typeof t==`string`){let e=Number(t);return Number.isFinite(e)?e:void 0}}function F(e,n,r){let i=e.frontend?.path;return(e,a)=>{let o=n(e),s=a??r;if(o.url===void 0&&i!==void 0&&s){let n=t(i,e,s,r);if(n!==null)return{...o,url:n}}return o}}function I(t){let n=t.createClient??e,r=[],a=new Map;for(let e of t.entities){let t=i(e);if(!t)continue;let r=n(e);a.set(e.name,{entity:e,config:t,client:r})}for(let{entity:e,config:n,client:i}of a.values()){let o=n.fts&&H(e,n)?V(e,n,i,a,t.defaultLocale):B(e,n,i,t.defaultLocale);if(o===null){console.warn(`[search-postgres] '${e.name}': searchable() declares no text field, so no search provider was built. Its fields still work as filters.`);continue}let s=t.resolveCanonicalPaths!==void 0&&e.frontend?.path!==void 0?y(o,e.name,t.resolveCanonicalPaths,t.defaultLocale):o,c=e.tokens===`deny`||(n.includes??[]).some(e=>t.entities.find(t=>t.name===e.entity)?.tokens===`deny`);r.push(c?L(s):s)}return t.extraProviders&&r.push(...t.extraProviders),r}function L(e){return{resource:e.resource,...e.permissionResource!==void 0&&{permissionResource:e.permissionResource},capabilities:e.capabilities,tokens:`deny`,search:(t,n)=>e.search(t,n)}}const R=new Set([`text`,`richtext`,`slug`,`select`]);function z(e,t){let n=e.allFields;return t.filter(e=>Object.hasOwn(n,e)&&R.has(n[e]?.type??``))}function B(e,t,n,i){let a=n,o=z(e,t.fields);return o.length===0?null:new S({resource:e.name,client:a,searchableFields:o,filterableFields:[...t.fields],translatableFields:r(e.allFields,o),transform:F(e,t.projection,i)})}function V(e,t,n,i,a){let o={};for(let n of t.includes??[]){let t=i.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`);o[n.entity]=t.client}let s=t.fts?new Set(t.fts.fields):new Set,c=z(e,t.fields),l=c.filter(e=>!s.has(e)),u=e.allFields,d=r(u,c),f=r(u,Object.keys(u)),p={resource:e.name,client:n,filterableFields:[...t.fields],ilikeFields:l,translatableFields:d,translationOverlayFields:f,transform:F(e,t.projection,a)};return e.scope!==void 0&&(p.entityScope=e.scope),a!==void 0&&(p.defaultLocale=a),t.fts?.language!==void 0&&(p.language=t.fts.language),t.includes&&t.includes.length>0&&(p.includes=t.includes.map(e=>({entity:e.entity,fk:e.fk})),p.includedClients=o),t.defaultFilters!==void 0&&(p.defaultFilters=t.defaultFilters),new A(p)}function H(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{T as DEFAULT_TSVECTOR_COLUMN,E as DEFAULT_TSVECTOR_LANGUAGE,S as IlikeProvider,A as TsvectorProvider,I as buildSearchProviders,y as withCanonicalUrls};
1
+ import{createAdminClient as e}from"@murumets-ee/core/clients";import{SITE_COLUMN as t,buildEntityHref as n,buildTranslationsSearchExists as r,filterTranslatableFields as i,getSearchableConfig as a,isSiteBound as o}from"@murumets-ee/entity";import{escapeLikePattern as s}from"@murumets-ee/core";import{SearchError as c,SearchErrorCodes as l}from"@murumets-ee/search";import{and as u,asc as d,desc as f,eq as p,getTableColumns as m,ilike as h,inArray as g,isNull as _,or as v,sql as y}from"drizzle-orm";import{unionAll as b}from"drizzle-orm/pg-core";function x(e,t,n,r){return{resource:e.resource,capabilities:e.capabilities,...e.permissionResource===void 0?{}:{permissionResource:e.permissionResource},async search(i,a){let o=await e.search(i,a),s=i.locale??r;if(s===void 0)return o;let c=o.rows.filter(e=>e.url===void 0).map(e=>e.id);if(c.length===0)return o;let l=await n(t,c,s);return l.size===0?o:{...o,rows:o.rows.map(e=>{if(e.url!==void 0)return e;let t=l.get(e.id);return t===void 0?e:{...e,url:t}})}}}}function S(e,t){return e[t]}const C={fullText:!1,ranking:!1,facets:!1,fuzzy:!1,prefix:!0};var w=class{resource;permissionResource;capabilities=C;client;searchableFields;filterableFieldSet;translatableFields;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(S(t,r)===void 0)throw Error(`IlikeProvider '${e.resource}' field '${r}' is not a column on the entity table`);let r=e.translatableFields??[];if(r.length>0){let t=e.client.getTranslationsTable?.()??null;if(t===null)throw Error(`IlikeProvider '${e.resource}' declares translatableFields but the entity has no translations table`);for(let n of r)if(S(t,n)===void 0)throw Error(`IlikeProvider '${e.resource}' translatable field '${n}' is not a column on the translations 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.translatableFields=r,this.transform=e.transform}async search(e,t){if(t.aborted)throw new c(`Search aborted`,504,l.Timeout);let n=this.buildQueryCondition(e);if(n===null)return{rows:[],total:0,facets:[],durationMs:0};let r=u(n,...this.buildFilterConditions(e.filters));if(r===void 0)throw Error(`unreachable: and() called with at least one arg`);let i=r,a={where:i,limit:e.limit,offset:e.offset};e.locale!==void 0&&(a.locale=e.locale);let o=Date.now(),s=await this.client.findMany(a);if(t.aborted)throw new c(`Search aborted`,504,l.Timeout);let d=Date.now()-o,f=await this.client.count({where:i});if(t.aborted)throw new c(`Search aborted`,504,l.Timeout);return{rows:s.map(t=>this.transform(t,e.locale)),total:f,facets:[],durationMs:d}}buildQueryCondition(e){let t=this.client.getTable(),n=[],r=T(e.query,e.mode);for(let i of this.searchableFields){let a=S(t,i);a!==void 0&&(e.mode===`term`?n.push(p(a,e.query)):n.push(h(a,r)))}let i=this.buildTranslationsCondition(t,e,r);if(i!==void 0&&n.push(i),n.length===0)return null;let a=n[0];if(a===void 0)return null;if(n.length===1)return a;let o=v(...n);if(o===void 0)throw Error(`unreachable: or() called with ≥ 2 args`);return o}buildTranslationsCondition(e,t,n){if(this.translatableFields.length===0)return;let i=this.client.getTranslationsTable?.()??null;if(i===null)return;let a=S(e,`id`);if(a===void 0)return;let o={idColumn:a,translationsTable:i,fields:this.translatableFields,buildCondition:e=>t.mode===`term`?p(e,t.query):h(e,n)};return t.locale!==void 0&&(o.locale=t.locale),r(o)}buildFilterConditions(e){let t=this.client.getTable(),n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=S(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(p(e,t))}else n.push(g(e,[...i]))}return n}};function T(e,t){let n=s(e);return t===`prefix`?`${n}%`:`%${n}%`}function E(e,t){return e[t]}const D=`_search_tsv`,O=`simple`,k=`__lumi_score`,A=`__lumi_total`,j={fullText:!0,ranking:!0,facets:!1,fuzzy:!1,prefix:!0};var M=class{resource;permissionResource;capabilities=j;client;vectorColumn;language;filterableFieldSet;transform;includes;defaultFilters;ilikeFields;translatableFields;translationOverlayFields;defaultLocale;constructor(e){let n=e.vectorColumn??`_search_tsv`,r=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 i=e.client.getTable();if(E(i,t)!==void 0)throw Error(`TsvectorProvider '${e.resource}' refuses a site-bound table: the raw FTS query would bypass AdminClient's site filter. Use IlikeProvider (buildSearchProviders does this automatically).`);if(E(i,n)===void 0)throw Error(`TsvectorProvider '${e.resource}' tsvector column '${n}' is not a column on the entity table`);for(let t of e.filterableFields)if(E(i,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(E(i,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(E(i,t)===void 0)throw Error(`TsvectorProvider '${e.resource}' ilikeFields entry '${t}' is not a column on the entity table`);let a=e.translatableFields??[],o=e.translationOverlayFields??[];if(a.length>0||o.length>0){let t=e.client.getTranslationsTable?.()??null;if(t===null)throw Error(`TsvectorProvider '${e.resource}' declares translatable fields but the entity has no translations table`);for(let n of[...a,...o])if(E(t,n)===void 0)throw Error(`TsvectorProvider '${e.resource}' translatable field '${n}' is not a column on the translations table`)}let s=[],c=e.includes??[],l=e.includedClients??{};for(let r of c){let i=l[r.entity];if(!i)throw Error(`TsvectorProvider '${e.resource}' include '${r.entity}' has no entry in includedClients`);let a=i.getTable();if(E(a,t)!==void 0)throw Error(`TsvectorProvider '${e.resource}' include '${r.entity}' is a site-bound table — the raw FTS UNION would bypass AdminClient's site filter.`);if(E(a,n)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${r.entity}' is missing tsvector column '${n}' — it must have its own searchable({ fts: ... }) declaration`);if(E(a,r.fk)===void 0)throw Error(`TsvectorProvider '${e.resource}' include '${r.entity}' has no FK column '${r.fk}'`);s.push({entity:r.entity,fk:r.fk,client:i})}this.resource=e.resource,e.permissionResource!==void 0&&(this.permissionResource=e.permissionResource),this.client=e.client,this.vectorColumn=n,this.language=r,this.filterableFieldSet=new Set(e.filterableFields),this.transform=e.transform,this.includes=s,this.defaultFilters=e.defaultFilters??{},this.ilikeFields=e.ilikeFields??[],this.translatableFields=a,this.translationOverlayFields=o,this.defaultLocale=e.defaultLocale}async search(e,t){if(t.aborted)throw new c(`Search aborted`,504,l.Timeout);let n=this.includes.length===0?await this.searchSingleEntity(e):await this.searchWithIncludes(e);if(t.aborted)throw new c(`Search aborted`,504,l.Timeout);return n}async searchSingleEntity(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=E(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=N(e.query,e.mode,this.language),a=y`ts_rank_cd(${n}, ${i})`,o=this.buildMatchExpr(t,n,i,e.query,e.locale),s=this.buildAllFilterConditions(e.filters,t),c=s.length===0?o:u(o,...s);if(c===void 0)throw Error(`unreachable: and() called with at least one arg`);let l=t,p=Date.now(),h=await this.client.getDb().select({...m(l),[k]:a,[A]:y`count(*) OVER ()::int`}).from(l).where(c).orderBy(f(a),d(r)).limit(e.limit).offset(e.offset),g=await this.overlayTranslations(h,e.locale),_=Date.now()-p;return this.shapeResult(g,_,e.locale)}async searchWithIncludes(e){let t=this.client.getTable(),n=this.resolveTsvCol(t),r=E(t,`id`);if(r===void 0)throw Error(`TsvectorProvider '${this.resource}' table has no 'id' column for ordering`);let i=N(e.query,e.mode,this.language),a=this.client.getDb().select({parent_id:y`${r}`.as(`parent_id`),score:y`ts_rank_cd(${n}, ${i})`.as(`score`)}).from(t).where(this.buildMatchExpr(t,n,i,e.query,e.locale)),o=this.includes.map(e=>{let t=e.client.getTable(),n=this.resolveTsvCol(t),r=E(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:y`${r}`.as(`parent_id`),score:y`MAX(ts_rank_cd(${n}, ${i}))`.as(`score`)}).from(t).where((()=>{let e=y`${n} @@ ${i}`,r=this.buildTrashFilter(t);return r?u(e,r)??e:e})()).groupBy(y`${r}`)}),s=o[0];if(!s)throw Error(`TsvectorProvider '${this.resource}' searchWithIncludes invoked with empty includes`);let c=b(a,s,...o.slice(1)).as(`union_hits`),l=this.client.getDb().select({parent_id:y`${c.parent_id}`.as(`parent_id`),score:y`MAX(${c.score})`.as(`score`)}).from(c).groupBy(c.parent_id).as(`h`),h=this.buildAllFilterConditions(e.filters,t),g=t,_=Date.now(),v=this.client.getDb().select({...m(g),[k]:y`${l.score}`,[A]:y`count(*) OVER ()::int`}).from(l).innerJoin(g,p(l.parent_id,r)),x=await(h.length===0?v:v.where(u(...h))).orderBy(f(l.score),d(r)).limit(e.limit).offset(e.offset),S=await this.overlayTranslations(x,e.locale),C=Date.now()-_;return this.shapeResult(S,C,e.locale)}resolveTsvCol(e){let t=E(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,i){let a=y`${t} @@ ${n}`,o=`%${s(r)}%`,c=[a];for(let t of this.ilikeFields){let n=E(e,t);n!==void 0&&c.push(h(n,o))}let l=this.buildTranslationsCondition(e,o,i);return l!==void 0&&c.push(l),c.length===1?a:v(...c)??a}buildTranslationsCondition(e,t,n){if(this.translatableFields.length===0)return;let i=this.client.getTranslationsTable?.()??null;if(i===null)return;let a=E(e,`id`);if(a===void 0)return;let o={idColumn:a,translationsTable:i,fields:this.translatableFields,buildCondition:e=>h(e,t)};return n!==void 0&&(o.locale=n),r(o)}async overlayTranslations(e,t){if(t===void 0||e.length===0||t===this.defaultLocale||this.translationOverlayFields.length===0)return e;let n=this.client.getTranslationsTable?.()??null;if(n===null)return e;let r=E(n,`entityId`),i=E(n,`locale`);if(r===void 0||i===void 0)return e;let a=e.map(e=>e.id).filter(e=>typeof e==`string`);if(a.length===0)return e;let o=await this.client.getDb().select().from(n).where(u(g(r,a),p(i,t))),s=new Map;for(let e of o){let t={};for(let n of this.translationOverlayFields){let r=e[n];r!=null&&(t[n]=r)}s.set(e.entityId,t)}return e.map(e=>{let t=s.get(e.id);return t?{...e,...t}:e})}shapeResult(e,t,n){let r=P(e);return{rows:e.map(e=>{let t=this.transform(F(e),n),r=I(e);return r===void 0?t:{...t,score:r}}),total:r,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)}let i=this.buildTrashFilter(t);return i&&n.push(i),n}buildTrashFilter(e){let t=E(e,`deletedAt`);return t?_(t):void 0}buildSingleFilterCondition(e,t,n){let r=E(n,e);if(r!==void 0&&t.length!==0){if(t.length===1){let e=t[0];return e===void 0?void 0:p(r,e)}return g(r,[...t])}}buildFilterConditions(e,t){let n=[];for(let[r,i]of Object.entries(e)){if(!this.filterableFieldSet.has(r))continue;let e=E(t,r);if(e!==void 0&&i.length!==0)if(i.length===1){let t=i[0];if(t===void 0)continue;n.push(p(e,t))}else n.push(g(e,[...i]))}return n}};function N(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?y`to_tsquery(${n}, ${`__no_match__:*`})`:y`to_tsquery(${n}, ${t.map(e=>`${e}:*`).join(` & `)})`}case`phrase`:return y`phraseto_tsquery(${n}, ${e})`;default:return y`websearch_to_tsquery(${n}, ${e})`}}function P(e){if(e.length===0)return 0;let t=e[0];if(!t)return 0;let n=t[A];if(typeof n==`number`)return n;if(typeof n==`string`){let e=Number(n);return Number.isFinite(e)?e:0}return 0}function F(e){let t={};for(let[n,r]of Object.entries(e))n===k||n===A||(t[n]=r);return t}function I(e){let t=e[k];if(typeof t==`number`)return t;if(typeof t==`string`){let e=Number(t);return Number.isFinite(e)?e:void 0}}function L(e,t,r){let i=e.frontend?.path;return(e,a)=>{let o=t(e),s=a??r;if(o.url===void 0&&i!==void 0&&s){let t=n(i,e,s,r);if(t!==null)return{...o,url:t}}return o}}function R(t){let n=t.createClient??e,r=[],i=new Map;for(let e of t.entities){let t=a(e);if(!t)continue;let r=n(e);i.set(e.name,{entity:e,config:t,client:r})}for(let{entity:e,config:n,client:a}of i.values()){let s=o(e)||(n.includes??[]).some(e=>{let t=i.get(e.entity);return t!==void 0&&o(t.entity)});if(s&&n.fts&&((n.includes?.length??0)>0||n.defaultFilters!==void 0))throw Error(`searchable() on "${e.name}": fts with includes/defaultFilters is not supported on a site-bound entity (or one including a site-bound entity) — the raw FTS path would read every site, and the site-confined ILIKE path supports neither. Drop includes / defaultFilters, or keep the entity shared.`);let c=n.fts&&W(e,n)&&!s?U(e,n,a,i,t.defaultLocale):H(e,n,a,t.defaultLocale);if(c===null){console.warn(`[search-postgres] '${e.name}': searchable() declares no text field, so no search provider was built. Its fields still work as filters.`);continue}let l=t.resolveCanonicalPaths!==void 0&&e.frontend?.path!==void 0?x(c,e.name,t.resolveCanonicalPaths,t.defaultLocale):c,u=e.tokens===`deny`||(n.includes??[]).some(e=>t.entities.find(t=>t.name===e.entity)?.tokens===`deny`);r.push(u?z(l):l)}return t.extraProviders&&r.push(...t.extraProviders),r}function z(e){return{resource:e.resource,...e.permissionResource!==void 0&&{permissionResource:e.permissionResource},capabilities:e.capabilities,tokens:`deny`,search:(t,n)=>e.search(t,n)}}const B=new Set([`text`,`richtext`,`slug`,`select`]);function V(e,t){let n=e.allFields;return t.filter(e=>Object.hasOwn(n,e)&&B.has(n[e]?.type??``))}function H(e,t,n,r){let a=n,o=V(e,t.fields);return o.length===0?null:new w({resource:e.name,client:a,searchableFields:o,filterableFields:[...t.fields],translatableFields:i(e.allFields,o),transform:L(e,t.projection,r)})}function U(e,t,n,r,a){let o={};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`);o[n.entity]=t.client}let s=t.fts?new Set(t.fts.fields):new Set,c=V(e,t.fields),l=c.filter(e=>!s.has(e)),u=e.allFields,d=i(u,c),f=i(u,Object.keys(u)),p={resource:e.name,client:n,filterableFields:[...t.fields],ilikeFields:l,translatableFields:d,translationOverlayFields:f,transform:L(e,t.projection,a)};return e.scope!==void 0&&(p.entityScope=e.scope),a!==void 0&&(p.defaultLocale=a),t.fts?.language!==void 0&&(p.language=t.fts.language),t.includes&&t.includes.length>0&&(p.includes=t.includes.map(e=>({entity:e.entity,fk:e.fk})),p.includedClients=o),t.defaultFilters!==void 0&&(p.defaultFilters=t.defaultFilters),new M(p)}function W(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{D as DEFAULT_TSVECTOR_COLUMN,O as DEFAULT_TSVECTOR_LANGUAGE,w as IlikeProvider,M as TsvectorProvider,R as buildSearchProviders,x as withCanonicalUrls};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["resolveColumn"],"sources":["../src/canonical-url.ts","../src/ilike-provider.ts","../src/tsvector-provider.ts","../src/bootstrap.ts"],"sourcesContent":["/**\n * Search-result URL resolution from the `toolkit_paths` index (plan/routing\n * PR06 Task 2, D005 — one mechanism for editor links, search URLs, menus, and\n * frontends).\n *\n * `adaptProjection` computes a result `url` per row from `frontend.path` via the\n * pure `buildEntityHref`. That works for simple patterns but returns `null` for\n * hierarchical `[parent.path]` entities (a single row can't expand its parent's\n * path), so those search results ship with no URL. This decorator fills those\n * gaps from the materialized index: after the inner provider returns a page of\n * results, it batch-resolves the canonical paths for the rows that still lack a\n * URL in ONE call (never one per row — CLAUDE.md no-unbounded-fan-out; the page\n * is already capped by the search mechanics layer) and overlays them.\n *\n * **Gap-fill only.** A row that already carries a `url` (projection-set, or\n * buildEntityHref-computed for a simple pattern) is left untouched — the index\n * only supplies URLs the per-row builder couldn't. This keeps the decorator\n * strictly additive: it never clobbers an existing URL.\n *\n * **Leaf/decoupled.** The index read is injected as {@link BatchCanonicalPathResolver}\n * (wired by the app from `@murumets-ee/content/resolve`'s `getCanonicalPaths`),\n * so `@murumets-ee/search-postgres` keeps its minimal dependency set — it never\n * imports the content package.\n */\n\nimport type { SearchProvider } from '@murumets-ee/search'\n\n/**\n * Resolve the canonical paths for many entity rows of ONE type in ONE locale,\n * as an `entityId → path` map. Ids absent from the index are omitted. Backed by\n * `@murumets-ee/content/resolve`'s batched `getCanonicalPaths` (one index query\n * per page), injected by the app so this package stays content-free.\n */\nexport type BatchCanonicalPathResolver = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlyMap<string, string>>\n\n/**\n * Wrap `inner` so its results' `url`s are gap-filled from the `toolkit_paths`\n * index for the given `entityType`. `defaultLocale` is used when a search\n * carries no locale (the global search box); when neither is present, resolution\n * is skipped (no locale → no canonical URL to look up).\n */\nexport function withCanonicalUrls(\n inner: SearchProvider,\n entityType: string,\n resolve: BatchCanonicalPathResolver,\n defaultLocale: string | undefined,\n): SearchProvider {\n const wrapped: SearchProvider = {\n resource: inner.resource,\n capabilities: inner.capabilities,\n ...(inner.permissionResource !== undefined\n ? { permissionResource: inner.permissionResource }\n : {}),\n async search(input, signal) {\n const result = await inner.search(input, signal)\n const locale = input.locale ?? defaultLocale\n if (locale === undefined) return result\n\n const missingIds = result.rows.filter((r) => r.url === undefined).map((r) => r.id)\n if (missingIds.length === 0) return result\n\n const paths = await resolve(entityType, missingIds, locale)\n if (paths.size === 0) return result\n\n return {\n ...result,\n rows: result.rows.map((r) => {\n if (r.url !== undefined) return r\n const path = paths.get(r.id)\n return path !== undefined ? { ...r, url: path } : r\n }),\n }\n },\n }\n return wrapped\n}\n","import { escapeLikePattern } from '@murumets-ee/core'\nimport { buildTranslationsSearchExists } from '@murumets-ee/entity'\nimport type { CountOptions, FindManyOptions } from '@murumets-ee/entity/admin'\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 { 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 * The `<entity>_translations` table, or `null` when the entity has none.\n * Matches `AdminClient.getTranslationsTable()` — used to build the\n * translation-aware WHERE (#543). Optional so pre-existing custom\n * client objects keep compiling; absent is treated as `null` (the\n * constructor still throws when `translatableFields` is declared\n * without a resolvable table).\n */\n getTranslationsTable?(): PgTableLike | null\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 * Subset of `searchableFields` declared `translatable: true` on the\n * entity. These fields live as nullable override columns on\n * `<entity>_translations`; the provider ORs a correlated EXISTS on\n * that table into the WHERE so translated content matches (#543).\n * When the search carries a locale the EXISTS is locale-constrained;\n * locale-less searches match any locale.\n */\n translatableFields?: 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, locale?: string) => 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 translatableFields: readonly string[]\n private readonly transform: (row: TRow, locale?: string) => SearchResultRow\n\n constructor(config: IlikeProviderConfig<TRow>) {\n if (config.searchableFields.length === 0) {\n throw new Error(`IlikeProvider '${config.resource}' requires at least one searchable field`)\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 // Translatable searchable fields must resolve to columns on the\n // translations table — same fail-fast posture as the main-table check\n // above. A typo (or a missing translation table) would silently turn\n // \"search matches translated content\" back into the #543 bug.\n const translatableFields = config.translatableFields ?? []\n if (translatableFields.length > 0) {\n const translationsTable = config.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) {\n throw new Error(\n `IlikeProvider '${config.resource}' declares translatableFields but the entity has no translations table`,\n )\n }\n for (const field of translatableFields) {\n if (resolveColumn(translationsTable, field) === undefined) {\n throw new Error(\n `IlikeProvider '${config.resource}' translatable field '${field}' is not a column on the translations table`,\n )\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.translatableFields = translatableFields\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, input.locale)),\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 * Translatable fields additionally match via a correlated EXISTS on\n * `<entity>_translations` (#543) — base-row values still match through\n * the main-table pieces, so untranslated rows behave exactly as before.\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 const translationsExists = this.buildTranslationsCondition(table, input, pattern)\n if (translationsExists !== undefined) pieces.push(translationsExists)\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 * Build the correlated EXISTS on `<entity>_translations` matching the\n * translatable subset of `searchableFields` (#543). Returns `undefined`\n * when the entity has no translatable searchable fields or the main\n * table has no `id` column to correlate on.\n */\n private buildTranslationsCondition(\n table: PgTableLike,\n input: SearchInput,\n pattern: string,\n ): SQL | undefined {\n if (this.translatableFields.length === 0) return undefined\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return undefined\n const idColumn = resolveColumn(table, 'id')\n if (idColumn === undefined) return undefined\n\n const options: Parameters<typeof buildTranslationsSearchExists>[0] = {\n idColumn,\n translationsTable,\n fields: this.translatableFields,\n buildCondition: (column) =>\n input.mode === 'term' ? eq(column, input.query) : ilike(column, pattern),\n }\n if (input.locale !== undefined) options.locale = input.locale\n return buildTranslationsSearchExists(options)\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 { escapeLikePattern } from '@murumets-ee/core'\nimport { buildTranslationsSearchExists } from '@murumets-ee/entity'\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 ilike,\n inArray,\n isNull,\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 * The `<entity>_translations` table, or `null` when the entity has none.\n * Matches `AdminClient.getTranslationsTable()` — used for the\n * translation-aware WHERE and the locale row-overlay (#543). Optional so\n * pre-existing custom client objects keep compiling; absent is treated\n * as `null` (the constructor still throws when translatable fields are\n * declared without a resolvable table).\n */\n getTranslationsTable?(): PgTableLike | null\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 * Subset of the declared searchable fields that are `translatable: true`\n * on the entity. The provider ORs a correlated EXISTS on\n * `<entity>_translations` (ILIKE across these fields) into the match\n * expression so translated content is findable (#543). Locale-constrained\n * when the search carries a locale; any-locale otherwise.\n */\n translatableFields?: readonly string[]\n /**\n * ALL `translatable: true` field names on the entity (not just the\n * searchable subset). When a search carries a locale, the provider\n * overlays that locale's translation values onto the raw FTS rows\n * before calling `transform` — the raw select bypasses\n * `AdminClient.findMany`'s locale merge, so without this overlay a\n * non-default-locale search projects DEFAULT-locale labels and builds\n * URLs mixing the search locale's `[locale]` token with default-locale\n * row values (#543 finding 2).\n */\n translationOverlayFields?: readonly string[]\n /**\n * The app's default content locale. Default-locale content lives on the\n * BASE row by convention — the translations table has no rows for it —\n * so when `input.locale === defaultLocale` the row-overlay's extra\n * SELECT is skipped (the common-case search pays no extra round-trip).\n */\n defaultLocale?: 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>, locale?: string) => SearchResultRow\n}\n\nconst TSVECTOR_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\ninterface ResolvedInclude {\n entity: string\n fk: string\n client: TsvectorAdminClientLike\n}\n\n/**\n * Provider implementing FTS over a Postgres `tsvector` column.\n *\n * Wire it via `searchable({ fts: ... })` on the entity; `buildSearchProviders`\n * picks up the declaration and includes one instance in the provider array\n * passed to `searchRoutes({ providers })`. No imperative registration step.\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>, locale?: string) => SearchResultRow\n private readonly includes: readonly ResolvedInclude[]\n private readonly defaultFilters: Readonly<Record<string, readonly string[]>>\n private readonly ilikeFields: readonly string[]\n private readonly translatableFields: readonly string[]\n private readonly translationOverlayFields: readonly string[]\n private readonly defaultLocale: string | undefined\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 // Translation-aware fields (#543) — both lists must resolve to columns\n // on the translations table, and declaring either implies the table\n // exists. Fail fast: a typo here silently re-opens the \"search can't\n // find translated content\" bug.\n const translatableFields = config.translatableFields ?? []\n const translationOverlayFields = config.translationOverlayFields ?? []\n if (translatableFields.length > 0 || translationOverlayFields.length > 0) {\n const translationsTable = config.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) {\n throw new Error(\n `TsvectorProvider '${config.resource}' declares translatable fields but the entity has no translations table`,\n )\n }\n for (const field of [...translatableFields, ...translationOverlayFields]) {\n if (resolveColumn(translationsTable, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' translatable field '${field}' is not a column on the translations table`,\n )\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 this.translatableFields = translatableFields\n this.translationOverlayFields = translationOverlayFields\n this.defaultLocale = config.defaultLocale\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, input.locale)\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 merged = await this.overlayTranslations(rows, input.locale)\n const durationMs = Date.now() - start\n return this.shapeResult(merged, durationMs, input.locale)\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, input.locale))\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(\n // No-leak: a trashed included row (e.g. a trashed ticket_message)\n // must not surface its parent. AND the included table's trash filter\n // when present.\n ((): SQL => {\n const match = sql`${incTsvCol} @@ ${tsQueryExpr}`\n const trashed = this.buildTrashFilter(incTable)\n return trashed ? (and(match, trashed) ?? match) : match\n })(),\n )\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 merged = await this.overlayTranslations(rows, input.locale)\n const durationMs = Date.now() - start\n return this.shapeResult(merged, durationMs, input.locale)\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 OR a correlated EXISTS on the\n * translations table for translatable searchable fields (#543). The\n * user-typed query is escaped with `escapeLikePattern` before the\n * `%...%` wrap so LIKE wildcards in input don't widen the match.\n *\n * Rows matching ONLY via ILIKE / translations get `score = 0` from\n * `ts_rank_cd` (FTS didn't match, so ranking is genuinely zero).\n * FTS-ranked rows 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 locale?: string,\n ): SQL {\n const tsMatch = sql`${tsvCol} @@ ${tsQueryExpr}`\n const pattern = `%${escapeLikePattern(rawQuery)}%`\n const pieces: SQL[] = [tsMatch]\n\n for (const fieldName of this.ilikeFields) {\n const column = resolveColumn(table, fieldName)\n if (column === undefined) continue\n pieces.push(ilike(column, pattern))\n }\n\n const translationsExists = this.buildTranslationsCondition(table, pattern, locale)\n if (translationsExists !== undefined) pieces.push(translationsExists)\n\n if (pieces.length === 1) return tsMatch\n const combined = or(...pieces)\n return combined ?? tsMatch\n }\n\n /**\n * Correlated EXISTS on `<entity>_translations` matching the translatable\n * subset of the declared searchable fields (#543). `undefined` when the\n * entity has none or the main table has no `id` to correlate on.\n *\n * Deliberately substring-ILIKE in EVERY mode: the main-table tsquery is\n * prefix-anchored in `prefix` mode and equality-checked in IlikeProvider's\n * `term` mode, so translations matching here is slightly BROADER than the\n * main-table semantics. Acceptable for v1 — a translations-side rank/match\n * parity pass rides on the translation-table FTS follow-up (see #555).\n */\n private buildTranslationsCondition(\n table: PgTableLike,\n pattern: string,\n locale?: string,\n ): SQL | undefined {\n if (this.translatableFields.length === 0) return undefined\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return undefined\n const idColumn = resolveColumn(table, 'id')\n if (idColumn === undefined) return undefined\n\n const options: Parameters<typeof buildTranslationsSearchExists>[0] = {\n idColumn,\n translationsTable,\n fields: this.translatableFields,\n buildCondition: (column) => ilike(column, pattern),\n }\n if (locale !== undefined) options.locale = locale\n return buildTranslationsSearchExists(options)\n }\n\n /**\n * Overlay the search locale's translation values onto raw FTS rows\n * (#543 finding 2). The raw select bypasses `AdminClient.findMany`'s\n * locale merge, so without this a non-default-locale search feeds\n * DEFAULT-locale values to `transform` — wrong labels, and `url`s\n * mixing the search locale's `[locale]` token with default-locale row\n * values. Mirrors `mergeTranslations` semantics: only non-null\n * translation values override; untranslated rows pass through.\n */\n private async overlayTranslations(\n rows: Record<string, unknown>[],\n locale?: string,\n ): Promise<Record<string, unknown>[]> {\n if (locale === undefined || rows.length === 0) return rows\n // Default-locale content lives on the base row — no translation rows\n // exist for it by convention, so the overlay SELECT would be a wasted\n // round-trip on the most common search.\n if (locale === this.defaultLocale) return rows\n if (this.translationOverlayFields.length === 0) return rows\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return rows\n const entityIdCol = resolveColumn(translationsTable, 'entityId')\n const localeCol = resolveColumn(translationsTable, 'locale')\n if (entityIdCol === undefined || localeCol === undefined) return rows\n\n const ids = rows.map((row) => row.id).filter((id): id is string => typeof id === 'string')\n if (ids.length === 0) return rows\n\n const translations = await this.client\n .getDb()\n .select()\n .from(translationsTable as unknown as PgTable)\n .where(and(inArray(entityIdCol, ids), eq(localeCol, locale)))\n\n const overlayByEntityId = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations as Record<string, unknown>[]) {\n const overlay: Record<string, unknown> = {}\n for (const field of this.translationOverlayFields) {\n const value = translation[field]\n if (value !== undefined && value !== null) overlay[field] = value\n }\n overlayByEntityId.set(translation.entityId, overlay)\n }\n\n return rows.map((row) => {\n const overlay = overlayByEntityId.get(row.id)\n return overlay ? { ...row, ...overlay } : row\n })\n }\n\n private shapeResult(\n rows: Record<string, unknown>[],\n durationMs: number,\n locale?: string,\n ): SearchResult {\n const total = readTotal(rows)\n const resultRows = rows.map((row) => {\n const projected = this.transform(stripWindowColumns(row), locale)\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 // No-leak (S2): trashable entities exclude trashed rows from FTS. The raw\n // FTS query bypasses `AdminClient.findMany`, so the filter is added here —\n // covering BOTH the single-entity WHERE and the with-includes OUTER select\n // (both compose this method against the main table). Detected by column\n // presence (only trashable entities carry `deletedAt`).\n const trashed = this.buildTrashFilter(table)\n if (trashed) out.push(trashed)\n return out\n }\n\n /**\n * `deletedAt IS NULL` for trashable tables (detected by column presence),\n * else `undefined`. Mirrors `AdminClient.buildTrashFilter`'s default mode —\n * typed search never surfaces trashed rows (there's no Trash view here).\n */\n private buildTrashFilter(table: PgTableLike): SQL | undefined {\n const col = resolveColumn(table, 'deletedAt')\n return col ? isNull(col) : undefined\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 `SearchProvider[]`\n * with one provider per `searchable()`-declaring entity.\n *\n * Consumer pattern (one call in the admin API handler setup):\n *\n * ```ts\n * import { buildSearchProviders } from '@murumets-ee/search-postgres'\n * import { searchRoutes, proxyClientIp } from '@murumets-ee/search/admin'\n *\n * const providers = buildSearchProviders({ entities: crudEntities })\n *\n * const routes = [\n * ...searchRoutes({ providers, getClientIp: proxyClientIp }),\n * // ...\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 * Resource-name collisions across entity-derived providers and\n * `extraProviders` are NOT checked here. They surface at framework\n * registration: `searchRoutes({providers})` → `combineAdminRoutes` throws\n * on duplicate `(prefix, method, path)` tuples, since `path` is the\n * provider's resource name. Same protection the legacy `SearchRegistry`\n * gave via its `register()` collision check, structurally enforced one\n * layer up.\n */\n\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport {\n buildEntityHref,\n type Entity,\n filterTranslatableFields,\n getSearchableConfig,\n type SearchableConfig,\n type SearchableResultRow,\n} from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { SearchProvider, SearchResultRow } from '@murumets-ee/search'\nimport { type BatchCanonicalPathResolver, withCanonicalUrls } from './canonical-url'\nimport { type AdminClientLike, IlikeProvider } from './ilike-provider'\nimport { type TsvectorAdminClientLike, TsvectorProvider } from './tsvector-provider'\n\nexport interface BuildSearchProvidersOptions {\n /**\n * All entities the admin UI exposes. Entities without a `searchable()`\n * behavior are skipped (no provider produced → no `defineAdminRoute`\n * entry → admin search route returns 404 for that resource, EntityList\n * toolbar disables the search input).\n */\n entities: readonly Entity[]\n /**\n * Additional pre-built providers to include alongside the entity-derived\n * ones. Use this for non-`defineEntity` resources — the canonical case\n * is the commerce parts catalog (Elasticsearch-backed, exposed under\n * 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 * Locale used to build result `url`s from `frontend.path` when the\n * request carries no `locale` query param (e.g. the global admin search\n * box). Without it, locale-less searches return rows with no `url` while\n * the picker (which sends a locale) gets one — inconsistent results for\n * the same query.\n */\n defaultLocale?: string\n /**\n * Batched `toolkit_paths` reader (routing PR06 / D005). When provided, every\n * entity-derived provider whose entity declares `frontend.path` is wrapped so\n * its result `url`s are gap-filled from the materialized index — the ONLY way\n * to resolve hierarchical `[parent.path]` URLs a per-row `buildEntityHref`\n * returns `null` for. Injected by the app (from `@murumets-ee/content/resolve`'s\n * `getCanonicalPaths` + `buildCanonicalPathsBatchDeps`) so this package stays\n * content-free. Omit it and search falls back to `buildEntityHref` URLs only.\n */\n resolveCanonicalPaths?: BatchCanonicalPathResolver\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 */\nexport function adaptProjection(\n entity: Entity,\n projection: (row: Record<string, unknown>) => SearchableResultRow,\n defaultLocale?: string,\n): (row: Record<string, unknown>, locale?: string) => SearchResultRow {\n const path = entity.frontend?.path\n return (row, locale) => {\n const result = projection(row)\n // Auto-populate `url` from the entity's declarative `frontend.path` (SD020)\n // so the link picker (and any consumer) gets a resolvable href without each\n // projection hand-maintaining one — single source of truth, computed in the\n // response (the Prismic pattern). Locale-aware, falling back to the\n // configured default locale for locale-less requests (global search box);\n // skipped when the projection already set a url or the entity declares no\n // frontend path.\n const urlLocale = locale ?? defaultLocale\n if (result.url === undefined && path !== undefined && urlLocale) {\n const href = buildEntityHref(path, row, urlLocale, defaultLocale)\n if (href !== null) return { ...result, url: href }\n }\n return result\n }\n}\n\n/**\n * Build a `SearchProvider[]` with one provider per `searchable()`-declaring\n * entity, in source-declaration order. Extra providers are appended.\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 buildSearchProviders(options: BuildSearchProvidersOptions): SearchProvider[] {\n const createClient = options.createClient ?? createAdminClient\n const providers: SearchProvider[] = []\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 base =\n config.fts && hasNonTranslatableFtsField(entity, config)\n ? buildTsvectorProvider(entity, config, client, searchableEntities, options.defaultLocale)\n : buildIlikeProvider(entity, config, client, options.defaultLocale)\n if (base === null) {\n // No text column to match. A provider over only booleans, numbers or\n // dates could never answer a search without a Postgres operator error,\n // and `IlikeProvider` refuses an empty list — so no provider, and the\n // dedicated search route 404s for this entity like any non-searchable\n // one. Said at boot, not silently.\n console.warn(\n `[search-postgres] '${entity.name}': searchable() declares no text field, so no search provider was built. Its fields still work as filters.`,\n )\n continue\n }\n // Re-point result URLs at the materialized index (D005) for routable\n // entities: fills the hierarchical `[parent.path]` URLs buildEntityHref\n // can't compute. Only entities declaring `frontend.path` are wrapped, and\n // only when the app injects the batched reader.\n const provider =\n options.resolveCanonicalPaths !== undefined && entity.frontend?.path !== undefined\n ? withCanonicalUrls(base, entity.name, options.resolveCanonicalPaths, options.defaultLocale)\n : base\n // agent-access D003: an entity that opted out of token doors — or one whose\n // `includes` search into such an entity — is not token-searchable either.\n const reachesDenied =\n entity.tokens === 'deny' ||\n (config.includes ?? []).some(\n (inc) => options.entities.find((e) => e.name === inc.entity)?.tokens === 'deny',\n )\n providers.push(reachesDenied ? denyTokens(provider) : provider)\n }\n\n // Append extras (commerce parts ES, etc.) after entity-derived providers.\n // Resource-name collisions surface at framework registration time\n // (`combineAdminRoutes` throws on duplicate `(prefix, method, path)`).\n // Same fail-loud protection the old `SearchRegistry.register()` gave.\n if (options.extraProviders) providers.push(...options.extraProviders)\n\n return providers\n}\n\n/**\n * The same provider, marked closed to token-derived doors. A delegating object\n * rather than a spread: providers are class instances and `search` lives on the\n * prototype.\n */\nfunction denyTokens(provider: SearchProvider): SearchProvider {\n return {\n resource: provider.resource,\n ...(provider.permissionResource !== undefined && {\n permissionResource: provider.permissionResource,\n }),\n capabilities: provider.capabilities,\n tokens: 'deny',\n search: (input, signal) => provider.search(input, signal),\n }\n}\n\n/** Field types whose column holds text, and so can take `ILIKE` / a string `eq`. */\nconst TEXT_SEARCH_TYPES: ReadonlySet<string> = new Set(['text', 'richtext', 'slug', 'select'])\n\n/**\n * The subset of `fields` whose declared type holds text — the ONLY columns any\n * matcher may touch. Every matcher list goes through this one filter: the\n * ILIKE provider's search and translatable lists, and the tsvector provider's\n * ILIKE and translatable lists. Filtering just one of them left a boolean\n * filter column reachable by another (CodeRabbit CLI, PR53).\n */\nfunction textSearchFieldsOf(entity: Entity, fields: readonly string[]): string[] {\n const declared = entity.allFields as Record<string, { type: string }>\n return fields.filter(\n (field) => Object.hasOwn(declared, field) && TEXT_SEARCH_TYPES.has(declared[field]?.type ?? ''),\n )\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 defaultLocale?: string,\n): SearchProvider | null {\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 // TEXT columns are matched; EVERY declared column stays filterable.\n //\n // `searchable.fields` is the filter whitelist as well as the search set, so\n // it can legitimately hold a boolean — a collection block's \"promoted only\"\n // filter needs one there (editor PR53). The provider ILIKEs (or, in `term`\n // mode, `eq`s against the typed query) every `searchableFields` entry, and\n // either one against a boolean column is a Postgres error, so a boolean in\n // the list turned every search on the entity into a 500. Same guard, same\n // type set, as the admin list's own search (`admin-ui` `query.ts`).\n //\n // An entity whose searchable fields are ALL non-text gets NO provider (`null`,\n // warned by the caller). Falling back to the declared list — the first cut —\n // handed those very columns back to the matcher and re-opened the error this\n // guard exists to close (CodeRabbit CLI, PR53).\n const textFields = textSearchFieldsOf(entity, config.fields)\n if (textFields.length === 0) return null\n return new IlikeProvider<Record<string, unknown>>({\n resource: entity.name,\n client: adminLike,\n searchableFields: textFields,\n filterableFields: [...config.fields],\n // Translatable searchable fields get a correlated EXISTS on\n // `<entity>_translations` in the WHERE (#543) — without it, content\n // living only in translation rows (e.g. an et page title) is\n // unfindable. Row VALUES are already locale-merged by\n // `AdminClient.findMany({ locale })`, so no overlay is needed here.\n translatableFields: filterTranslatableFields(\n entity.allFields as Record<string, { translatable?: boolean }>,\n textFields,\n ),\n transform: adaptProjection(entity, config.projection, defaultLocale),\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 defaultLocale?: string,\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 // Text columns only, for the reason `textSearchFieldsOf` gives: a boolean\n // filter column outside `fts.fields` would otherwise be ILIKE'd here.\n const textFields = textSearchFieldsOf(entity, config.fields)\n const ilikeFields = textFields.filter((f) => !ftsFieldSet.has(f))\n // Translation-aware search (#543): translatable searchable fields are\n // matched via correlated EXISTS on `<entity>_translations` (their values\n // — and their tsvector — live there, not on the main table). The overlay\n // list carries ALL translatable entity fields so the locale's values are\n // merged onto raw FTS rows before the projection runs (raw selects bypass\n // findMany's locale merge — finding 2 on #543).\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n const translatableSearchFields = filterTranslatableFields(allFields, textFields)\n const translationOverlayFields = filterTranslatableFields(allFields, Object.keys(allFields))\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 translatableFields: translatableSearchFields,\n translationOverlayFields,\n transform: adaptProjection(entity, config.projection, defaultLocale),\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 // Lets the provider skip the translation row-overlay for default-locale\n // searches (default-locale content lives on the base row by convention).\n if (defaultLocale !== undefined) {\n providerConfig.defaultLocale = defaultLocale\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/canonical-url.ts","../src/ilike-provider.ts","../src/tsvector-provider.ts","../src/bootstrap.ts"],"sourcesContent":["/**\n * Search-result URL resolution from the `toolkit_paths` index (plan/routing\n * PR06 Task 2, D005 — one mechanism for editor links, search URLs, menus, and\n * frontends).\n *\n * `adaptProjection` computes a result `url` per row from `frontend.path` via the\n * pure `buildEntityHref`. That works for simple patterns but returns `null` for\n * hierarchical `[parent.path]` entities (a single row can't expand its parent's\n * path), so those search results ship with no URL. This decorator fills those\n * gaps from the materialized index: after the inner provider returns a page of\n * results, it batch-resolves the canonical paths for the rows that still lack a\n * URL in ONE call (never one per row — CLAUDE.md no-unbounded-fan-out; the page\n * is already capped by the search mechanics layer) and overlays them.\n *\n * **Gap-fill only.** A row that already carries a `url` (projection-set, or\n * buildEntityHref-computed for a simple pattern) is left untouched — the index\n * only supplies URLs the per-row builder couldn't. This keeps the decorator\n * strictly additive: it never clobbers an existing URL.\n *\n * **Leaf/decoupled.** The index read is injected as {@link BatchCanonicalPathResolver}\n * (wired by the app from `@murumets-ee/content/resolve`'s `getCanonicalPaths`),\n * so `@murumets-ee/search-postgres` keeps its minimal dependency set — it never\n * imports the content package.\n */\n\nimport type { SearchProvider } from '@murumets-ee/search'\n\n/**\n * Resolve the canonical paths for many entity rows of ONE type in ONE locale,\n * as an `entityId → path` map. Ids absent from the index are omitted. Backed by\n * `@murumets-ee/content/resolve`'s batched `getCanonicalPaths` (one index query\n * per page), injected by the app so this package stays content-free.\n */\nexport type BatchCanonicalPathResolver = (\n entityType: string,\n entityIds: readonly string[],\n locale: string,\n) => Promise<ReadonlyMap<string, string>>\n\n/**\n * Wrap `inner` so its results' `url`s are gap-filled from the `toolkit_paths`\n * index for the given `entityType`. `defaultLocale` is used when a search\n * carries no locale (the global search box); when neither is present, resolution\n * is skipped (no locale → no canonical URL to look up).\n */\nexport function withCanonicalUrls(\n inner: SearchProvider,\n entityType: string,\n resolve: BatchCanonicalPathResolver,\n defaultLocale: string | undefined,\n): SearchProvider {\n const wrapped: SearchProvider = {\n resource: inner.resource,\n capabilities: inner.capabilities,\n ...(inner.permissionResource !== undefined\n ? { permissionResource: inner.permissionResource }\n : {}),\n async search(input, signal) {\n const result = await inner.search(input, signal)\n const locale = input.locale ?? defaultLocale\n if (locale === undefined) return result\n\n const missingIds = result.rows.filter((r) => r.url === undefined).map((r) => r.id)\n if (missingIds.length === 0) return result\n\n const paths = await resolve(entityType, missingIds, locale)\n if (paths.size === 0) return result\n\n return {\n ...result,\n rows: result.rows.map((r) => {\n if (r.url !== undefined) return r\n const path = paths.get(r.id)\n return path !== undefined ? { ...r, url: path } : r\n }),\n }\n },\n }\n return wrapped\n}\n","import { escapeLikePattern } from '@murumets-ee/core'\nimport { buildTranslationsSearchExists } from '@murumets-ee/entity'\nimport type { CountOptions, FindManyOptions } from '@murumets-ee/entity/admin'\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 { 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 * The `<entity>_translations` table, or `null` when the entity has none.\n * Matches `AdminClient.getTranslationsTable()` — used to build the\n * translation-aware WHERE (#543). Optional so pre-existing custom\n * client objects keep compiling; absent is treated as `null` (the\n * constructor still throws when `translatableFields` is declared\n * without a resolvable table).\n */\n getTranslationsTable?(): PgTableLike | null\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 * Subset of `searchableFields` declared `translatable: true` on the\n * entity. These fields live as nullable override columns on\n * `<entity>_translations`; the provider ORs a correlated EXISTS on\n * that table into the WHERE so translated content matches (#543).\n * When the search carries a locale the EXISTS is locale-constrained;\n * locale-less searches match any locale.\n */\n translatableFields?: 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, locale?: string) => 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 translatableFields: readonly string[]\n private readonly transform: (row: TRow, locale?: string) => SearchResultRow\n\n constructor(config: IlikeProviderConfig<TRow>) {\n if (config.searchableFields.length === 0) {\n throw new Error(`IlikeProvider '${config.resource}' requires at least one searchable field`)\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 // Translatable searchable fields must resolve to columns on the\n // translations table — same fail-fast posture as the main-table check\n // above. A typo (or a missing translation table) would silently turn\n // \"search matches translated content\" back into the #543 bug.\n const translatableFields = config.translatableFields ?? []\n if (translatableFields.length > 0) {\n const translationsTable = config.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) {\n throw new Error(\n `IlikeProvider '${config.resource}' declares translatableFields but the entity has no translations table`,\n )\n }\n for (const field of translatableFields) {\n if (resolveColumn(translationsTable, field) === undefined) {\n throw new Error(\n `IlikeProvider '${config.resource}' translatable field '${field}' is not a column on the translations table`,\n )\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.translatableFields = translatableFields\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, input.locale)),\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 * Translatable fields additionally match via a correlated EXISTS on\n * `<entity>_translations` (#543) — base-row values still match through\n * the main-table pieces, so untranslated rows behave exactly as before.\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 const translationsExists = this.buildTranslationsCondition(table, input, pattern)\n if (translationsExists !== undefined) pieces.push(translationsExists)\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 * Build the correlated EXISTS on `<entity>_translations` matching the\n * translatable subset of `searchableFields` (#543). Returns `undefined`\n * when the entity has no translatable searchable fields or the main\n * table has no `id` column to correlate on.\n */\n private buildTranslationsCondition(\n table: PgTableLike,\n input: SearchInput,\n pattern: string,\n ): SQL | undefined {\n if (this.translatableFields.length === 0) return undefined\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return undefined\n const idColumn = resolveColumn(table, 'id')\n if (idColumn === undefined) return undefined\n\n const options: Parameters<typeof buildTranslationsSearchExists>[0] = {\n idColumn,\n translationsTable,\n fields: this.translatableFields,\n buildCondition: (column) =>\n input.mode === 'term' ? eq(column, input.query) : ilike(column, pattern),\n }\n if (input.locale !== undefined) options.locale = input.locale\n return buildTranslationsSearchExists(options)\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 { escapeLikePattern } from '@murumets-ee/core'\nimport { buildTranslationsSearchExists, SITE_COLUMN } from '@murumets-ee/entity'\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 ilike,\n inArray,\n isNull,\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 * The `<entity>_translations` table, or `null` when the entity has none.\n * Matches `AdminClient.getTranslationsTable()` — used for the\n * translation-aware WHERE and the locale row-overlay (#543). Optional so\n * pre-existing custom client objects keep compiling; absent is treated\n * as `null` (the constructor still throws when translatable fields are\n * declared without a resolvable table).\n */\n getTranslationsTable?(): PgTableLike | null\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 * Subset of the declared searchable fields that are `translatable: true`\n * on the entity. The provider ORs a correlated EXISTS on\n * `<entity>_translations` (ILIKE across these fields) into the match\n * expression so translated content is findable (#543). Locale-constrained\n * when the search carries a locale; any-locale otherwise.\n */\n translatableFields?: readonly string[]\n /**\n * ALL `translatable: true` field names on the entity (not just the\n * searchable subset). When a search carries a locale, the provider\n * overlays that locale's translation values onto the raw FTS rows\n * before calling `transform` — the raw select bypasses\n * `AdminClient.findMany`'s locale merge, so without this overlay a\n * non-default-locale search projects DEFAULT-locale labels and builds\n * URLs mixing the search locale's `[locale]` token with default-locale\n * row values (#543 finding 2).\n */\n translationOverlayFields?: readonly string[]\n /**\n * The app's default content locale. Default-locale content lives on the\n * BASE row by convention — the translations table has no rows for it —\n * so when `input.locale === defaultLocale` the row-overlay's extra\n * SELECT is skipped (the common-case search pays no extra round-trip).\n */\n defaultLocale?: 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>, locale?: string) => SearchResultRow\n}\n\nconst TSVECTOR_CAPABILITIES: SearchCapabilities = {\n fullText: true,\n ranking: true,\n facets: false,\n fuzzy: false,\n prefix: true,\n}\n\ninterface ResolvedInclude {\n entity: string\n fk: string\n client: TsvectorAdminClientLike\n}\n\n/**\n * Provider implementing FTS over a Postgres `tsvector` column.\n *\n * Wire it via `searchable({ fts: ... })` on the entity; `buildSearchProviders`\n * picks up the declaration and includes one instance in the provider array\n * passed to `searchRoutes({ providers })`. No imperative registration step.\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>, locale?: string) => SearchResultRow\n private readonly includes: readonly ResolvedInclude[]\n private readonly defaultFilters: Readonly<Record<string, readonly string[]>>\n private readonly ilikeFields: readonly string[]\n private readonly translatableFields: readonly string[]\n private readonly translationOverlayFields: readonly string[]\n private readonly defaultLocale: string | undefined\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 // Same refusal for a SITE-bound table (multisite D003), detected from the\n // table itself so no caller can forget to forward it: a raw FTS select\n // would return every site's rows. `buildSearchProviders` routes bound\n // entities to IlikeProvider before ever reaching here.\n if (resolveColumn(table, SITE_COLUMN) !== undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' refuses a site-bound table: the raw FTS query would bypass AdminClient's site filter. Use IlikeProvider (buildSearchProviders does this automatically).`,\n )\n }\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 // Translation-aware fields (#543) — both lists must resolve to columns\n // on the translations table, and declaring either implies the table\n // exists. Fail fast: a typo here silently re-opens the \"search can't\n // find translated content\" bug.\n const translatableFields = config.translatableFields ?? []\n const translationOverlayFields = config.translationOverlayFields ?? []\n if (translatableFields.length > 0 || translationOverlayFields.length > 0) {\n const translationsTable = config.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) {\n throw new Error(\n `TsvectorProvider '${config.resource}' declares translatable fields but the entity has no translations table`,\n )\n }\n for (const field of [...translatableFields, ...translationOverlayFields]) {\n if (resolveColumn(translationsTable, field) === undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' translatable field '${field}' is not a column on the translations table`,\n )\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 // Same site-bound refusal as the main table: the UNION reads the\n // included table raw, past its client's site filter.\n if (resolveColumn(incTable, SITE_COLUMN) !== undefined) {\n throw new Error(\n `TsvectorProvider '${config.resource}' include '${inc.entity}' is a site-bound table — the raw FTS UNION would bypass AdminClient's site filter.`,\n )\n }\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 this.translatableFields = translatableFields\n this.translationOverlayFields = translationOverlayFields\n this.defaultLocale = config.defaultLocale\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, input.locale)\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 merged = await this.overlayTranslations(rows, input.locale)\n const durationMs = Date.now() - start\n return this.shapeResult(merged, durationMs, input.locale)\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, input.locale))\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(\n // No-leak: a trashed included row (e.g. a trashed ticket_message)\n // must not surface its parent. AND the included table's trash filter\n // when present.\n ((): SQL => {\n const match = sql`${incTsvCol} @@ ${tsQueryExpr}`\n const trashed = this.buildTrashFilter(incTable)\n return trashed ? (and(match, trashed) ?? match) : match\n })(),\n )\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 merged = await this.overlayTranslations(rows, input.locale)\n const durationMs = Date.now() - start\n return this.shapeResult(merged, durationMs, input.locale)\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 OR a correlated EXISTS on the\n * translations table for translatable searchable fields (#543). The\n * user-typed query is escaped with `escapeLikePattern` before the\n * `%...%` wrap so LIKE wildcards in input don't widen the match.\n *\n * Rows matching ONLY via ILIKE / translations get `score = 0` from\n * `ts_rank_cd` (FTS didn't match, so ranking is genuinely zero).\n * FTS-ranked rows 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 locale?: string,\n ): SQL {\n const tsMatch = sql`${tsvCol} @@ ${tsQueryExpr}`\n const pattern = `%${escapeLikePattern(rawQuery)}%`\n const pieces: SQL[] = [tsMatch]\n\n for (const fieldName of this.ilikeFields) {\n const column = resolveColumn(table, fieldName)\n if (column === undefined) continue\n pieces.push(ilike(column, pattern))\n }\n\n const translationsExists = this.buildTranslationsCondition(table, pattern, locale)\n if (translationsExists !== undefined) pieces.push(translationsExists)\n\n if (pieces.length === 1) return tsMatch\n const combined = or(...pieces)\n return combined ?? tsMatch\n }\n\n /**\n * Correlated EXISTS on `<entity>_translations` matching the translatable\n * subset of the declared searchable fields (#543). `undefined` when the\n * entity has none or the main table has no `id` to correlate on.\n *\n * Deliberately substring-ILIKE in EVERY mode: the main-table tsquery is\n * prefix-anchored in `prefix` mode and equality-checked in IlikeProvider's\n * `term` mode, so translations matching here is slightly BROADER than the\n * main-table semantics. Acceptable for v1 — a translations-side rank/match\n * parity pass rides on the translation-table FTS follow-up (see #555).\n */\n private buildTranslationsCondition(\n table: PgTableLike,\n pattern: string,\n locale?: string,\n ): SQL | undefined {\n if (this.translatableFields.length === 0) return undefined\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return undefined\n const idColumn = resolveColumn(table, 'id')\n if (idColumn === undefined) return undefined\n\n const options: Parameters<typeof buildTranslationsSearchExists>[0] = {\n idColumn,\n translationsTable,\n fields: this.translatableFields,\n buildCondition: (column) => ilike(column, pattern),\n }\n if (locale !== undefined) options.locale = locale\n return buildTranslationsSearchExists(options)\n }\n\n /**\n * Overlay the search locale's translation values onto raw FTS rows\n * (#543 finding 2). The raw select bypasses `AdminClient.findMany`'s\n * locale merge, so without this a non-default-locale search feeds\n * DEFAULT-locale values to `transform` — wrong labels, and `url`s\n * mixing the search locale's `[locale]` token with default-locale row\n * values. Mirrors `mergeTranslations` semantics: only non-null\n * translation values override; untranslated rows pass through.\n */\n private async overlayTranslations(\n rows: Record<string, unknown>[],\n locale?: string,\n ): Promise<Record<string, unknown>[]> {\n if (locale === undefined || rows.length === 0) return rows\n // Default-locale content lives on the base row — no translation rows\n // exist for it by convention, so the overlay SELECT would be a wasted\n // round-trip on the most common search.\n if (locale === this.defaultLocale) return rows\n if (this.translationOverlayFields.length === 0) return rows\n const translationsTable = this.client.getTranslationsTable?.() ?? null\n if (translationsTable === null) return rows\n const entityIdCol = resolveColumn(translationsTable, 'entityId')\n const localeCol = resolveColumn(translationsTable, 'locale')\n if (entityIdCol === undefined || localeCol === undefined) return rows\n\n const ids = rows.map((row) => row.id).filter((id): id is string => typeof id === 'string')\n if (ids.length === 0) return rows\n\n const translations = await this.client\n .getDb()\n .select()\n .from(translationsTable as unknown as PgTable)\n .where(and(inArray(entityIdCol, ids), eq(localeCol, locale)))\n\n const overlayByEntityId = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations as Record<string, unknown>[]) {\n const overlay: Record<string, unknown> = {}\n for (const field of this.translationOverlayFields) {\n const value = translation[field]\n if (value !== undefined && value !== null) overlay[field] = value\n }\n overlayByEntityId.set(translation.entityId, overlay)\n }\n\n return rows.map((row) => {\n const overlay = overlayByEntityId.get(row.id)\n return overlay ? { ...row, ...overlay } : row\n })\n }\n\n private shapeResult(\n rows: Record<string, unknown>[],\n durationMs: number,\n locale?: string,\n ): SearchResult {\n const total = readTotal(rows)\n const resultRows = rows.map((row) => {\n const projected = this.transform(stripWindowColumns(row), locale)\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 // No-leak (S2): trashable entities exclude trashed rows from FTS. The raw\n // FTS query bypasses `AdminClient.findMany`, so the filter is added here —\n // covering BOTH the single-entity WHERE and the with-includes OUTER select\n // (both compose this method against the main table). Detected by column\n // presence (only trashable entities carry `deletedAt`).\n const trashed = this.buildTrashFilter(table)\n if (trashed) out.push(trashed)\n return out\n }\n\n /**\n * `deletedAt IS NULL` for trashable tables (detected by column presence),\n * else `undefined`. Mirrors `AdminClient.buildTrashFilter`'s default mode —\n * typed search never surfaces trashed rows (there's no Trash view here).\n */\n private buildTrashFilter(table: PgTableLike): SQL | undefined {\n const col = resolveColumn(table, 'deletedAt')\n return col ? isNull(col) : undefined\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 `SearchProvider[]`\n * with one provider per `searchable()`-declaring entity.\n *\n * Consumer pattern (one call in the admin API handler setup):\n *\n * ```ts\n * import { buildSearchProviders } from '@murumets-ee/search-postgres'\n * import { searchRoutes, proxyClientIp } from '@murumets-ee/search/admin'\n *\n * const providers = buildSearchProviders({ entities: crudEntities })\n *\n * const routes = [\n * ...searchRoutes({ providers, getClientIp: proxyClientIp }),\n * // ...\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 * Resource-name collisions across entity-derived providers and\n * `extraProviders` are NOT checked here. They surface at framework\n * registration: `searchRoutes({providers})` → `combineAdminRoutes` throws\n * on duplicate `(prefix, method, path)` tuples, since `path` is the\n * provider's resource name. Same protection the legacy `SearchRegistry`\n * gave via its `register()` collision check, structurally enforced one\n * layer up.\n */\n\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport {\n buildEntityHref,\n type Entity,\n filterTranslatableFields,\n getSearchableConfig,\n isSiteBound,\n type SearchableConfig,\n type SearchableResultRow,\n} from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport type { SearchProvider, SearchResultRow } from '@murumets-ee/search'\nimport { type BatchCanonicalPathResolver, withCanonicalUrls } from './canonical-url'\nimport { type AdminClientLike, IlikeProvider } from './ilike-provider'\nimport { type TsvectorAdminClientLike, TsvectorProvider } from './tsvector-provider'\n\nexport interface BuildSearchProvidersOptions {\n /**\n * All entities the admin UI exposes. Entities without a `searchable()`\n * behavior are skipped (no provider produced → no `defineAdminRoute`\n * entry → admin search route returns 404 for that resource, EntityList\n * toolbar disables the search input).\n */\n entities: readonly Entity[]\n /**\n * Additional pre-built providers to include alongside the entity-derived\n * ones. Use this for non-`defineEntity` resources — the canonical case\n * is the commerce parts catalog (Elasticsearch-backed, exposed under\n * 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 * Locale used to build result `url`s from `frontend.path` when the\n * request carries no `locale` query param (e.g. the global admin search\n * box). Without it, locale-less searches return rows with no `url` while\n * the picker (which sends a locale) gets one — inconsistent results for\n * the same query.\n */\n defaultLocale?: string\n /**\n * Batched `toolkit_paths` reader (routing PR06 / D005). When provided, every\n * entity-derived provider whose entity declares `frontend.path` is wrapped so\n * its result `url`s are gap-filled from the materialized index — the ONLY way\n * to resolve hierarchical `[parent.path]` URLs a per-row `buildEntityHref`\n * returns `null` for. Injected by the app (from `@murumets-ee/content/resolve`'s\n * `getCanonicalPaths` + `buildCanonicalPathsBatchDeps`) so this package stays\n * content-free. Omit it and search falls back to `buildEntityHref` URLs only.\n */\n resolveCanonicalPaths?: BatchCanonicalPathResolver\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 */\nexport function adaptProjection(\n entity: Entity,\n projection: (row: Record<string, unknown>) => SearchableResultRow,\n defaultLocale?: string,\n): (row: Record<string, unknown>, locale?: string) => SearchResultRow {\n const path = entity.frontend?.path\n return (row, locale) => {\n const result = projection(row)\n // Auto-populate `url` from the entity's declarative `frontend.path` (SD020)\n // so the link picker (and any consumer) gets a resolvable href without each\n // projection hand-maintaining one — single source of truth, computed in the\n // response (the Prismic pattern). Locale-aware, falling back to the\n // configured default locale for locale-less requests (global search box);\n // skipped when the projection already set a url or the entity declares no\n // frontend path.\n const urlLocale = locale ?? defaultLocale\n if (result.url === undefined && path !== undefined && urlLocale) {\n const href = buildEntityHref(path, row, urlLocale, defaultLocale)\n if (href !== null) return { ...result, url: href }\n }\n return result\n }\n}\n\n/**\n * Build a `SearchProvider[]` with one provider per `searchable()`-declaring\n * entity, in source-declaration order. Extra providers are appended.\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 buildSearchProviders(options: BuildSearchProvidersOptions): SearchProvider[] {\n const createClient = options.createClient ?? createAdminClient\n const providers: SearchProvider[] = []\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 // Site-bound entities (multisite D003) — and anything whose UNION would\n // read a bound included table — never take the raw-FTS path: it selects\n // from the tables directly, past AdminClient's site filter, exactly the\n // bypass the scope refusal below guards against. IlikeProvider goes\n // through `AdminClient.findMany`, which confines every row to the site.\n const touchesBound =\n isSiteBound(entity) ||\n (config.includes ?? []).some((inc) => {\n const target = searchableEntities.get(inc.entity)\n return target !== undefined && isSiteBound(target.entity)\n })\n // IlikeProvider supports neither `includes` nor `defaultFilters`, so\n // falling back would SILENTLY drop them (message-body matches; \"closed\n // tickets hidden by default\"). Refused at boot instead, until scoped FTS\n // exists — no in-repo site-bound entity declares either.\n if (\n touchesBound &&\n config.fts &&\n ((config.includes?.length ?? 0) > 0 || config.defaultFilters !== undefined)\n ) {\n throw new Error(\n `searchable() on \"${entity.name}\": fts with includes/defaultFilters is not supported on a ` +\n 'site-bound entity (or one including a site-bound entity) — the raw FTS path would read ' +\n 'every site, and the site-confined ILIKE path supports neither. Drop includes / ' +\n 'defaultFilters, or keep the entity shared.',\n )\n }\n const base =\n config.fts && hasNonTranslatableFtsField(entity, config) && !touchesBound\n ? buildTsvectorProvider(entity, config, client, searchableEntities, options.defaultLocale)\n : buildIlikeProvider(entity, config, client, options.defaultLocale)\n if (base === null) {\n // No text column to match. A provider over only booleans, numbers or\n // dates could never answer a search without a Postgres operator error,\n // and `IlikeProvider` refuses an empty list — so no provider, and the\n // dedicated search route 404s for this entity like any non-searchable\n // one. Said at boot, not silently.\n console.warn(\n `[search-postgres] '${entity.name}': searchable() declares no text field, so no search provider was built. Its fields still work as filters.`,\n )\n continue\n }\n // Re-point result URLs at the materialized index (D005) for routable\n // entities: fills the hierarchical `[parent.path]` URLs buildEntityHref\n // can't compute. Only entities declaring `frontend.path` are wrapped, and\n // only when the app injects the batched reader.\n const provider =\n options.resolveCanonicalPaths !== undefined && entity.frontend?.path !== undefined\n ? withCanonicalUrls(base, entity.name, options.resolveCanonicalPaths, options.defaultLocale)\n : base\n // agent-access D003: an entity that opted out of token doors — or one whose\n // `includes` search into such an entity — is not token-searchable either.\n const reachesDenied =\n entity.tokens === 'deny' ||\n (config.includes ?? []).some(\n (inc) => options.entities.find((e) => e.name === inc.entity)?.tokens === 'deny',\n )\n providers.push(reachesDenied ? denyTokens(provider) : provider)\n }\n\n // Append extras (commerce parts ES, etc.) after entity-derived providers.\n // Resource-name collisions surface at framework registration time\n // (`combineAdminRoutes` throws on duplicate `(prefix, method, path)`).\n // Same fail-loud protection the old `SearchRegistry.register()` gave.\n if (options.extraProviders) providers.push(...options.extraProviders)\n\n return providers\n}\n\n/**\n * The same provider, marked closed to token-derived doors. A delegating object\n * rather than a spread: providers are class instances and `search` lives on the\n * prototype.\n */\nfunction denyTokens(provider: SearchProvider): SearchProvider {\n return {\n resource: provider.resource,\n ...(provider.permissionResource !== undefined && {\n permissionResource: provider.permissionResource,\n }),\n capabilities: provider.capabilities,\n tokens: 'deny',\n search: (input, signal) => provider.search(input, signal),\n }\n}\n\n/** Field types whose column holds text, and so can take `ILIKE` / a string `eq`. */\nconst TEXT_SEARCH_TYPES: ReadonlySet<string> = new Set(['text', 'richtext', 'slug', 'select'])\n\n/**\n * The subset of `fields` whose declared type holds text — the ONLY columns any\n * matcher may touch. Every matcher list goes through this one filter: the\n * ILIKE provider's search and translatable lists, and the tsvector provider's\n * ILIKE and translatable lists. Filtering just one of them left a boolean\n * filter column reachable by another (CodeRabbit CLI, PR53).\n */\nfunction textSearchFieldsOf(entity: Entity, fields: readonly string[]): string[] {\n const declared = entity.allFields as Record<string, { type: string }>\n return fields.filter(\n (field) => Object.hasOwn(declared, field) && TEXT_SEARCH_TYPES.has(declared[field]?.type ?? ''),\n )\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 defaultLocale?: string,\n): SearchProvider | null {\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 // TEXT columns are matched; EVERY declared column stays filterable.\n //\n // `searchable.fields` is the filter whitelist as well as the search set, so\n // it can legitimately hold a boolean — a collection block's \"promoted only\"\n // filter needs one there (editor PR53). The provider ILIKEs (or, in `term`\n // mode, `eq`s against the typed query) every `searchableFields` entry, and\n // either one against a boolean column is a Postgres error, so a boolean in\n // the list turned every search on the entity into a 500. Same guard, same\n // type set, as the admin list's own search (`admin-ui` `query.ts`).\n //\n // An entity whose searchable fields are ALL non-text gets NO provider (`null`,\n // warned by the caller). Falling back to the declared list — the first cut —\n // handed those very columns back to the matcher and re-opened the error this\n // guard exists to close (CodeRabbit CLI, PR53).\n const textFields = textSearchFieldsOf(entity, config.fields)\n if (textFields.length === 0) return null\n return new IlikeProvider<Record<string, unknown>>({\n resource: entity.name,\n client: adminLike,\n searchableFields: textFields,\n filterableFields: [...config.fields],\n // Translatable searchable fields get a correlated EXISTS on\n // `<entity>_translations` in the WHERE (#543) — without it, content\n // living only in translation rows (e.g. an et page title) is\n // unfindable. Row VALUES are already locale-merged by\n // `AdminClient.findMany({ locale })`, so no overlay is needed here.\n translatableFields: filterTranslatableFields(\n entity.allFields as Record<string, { translatable?: boolean }>,\n textFields,\n ),\n transform: adaptProjection(entity, config.projection, defaultLocale),\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 defaultLocale?: string,\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 // Text columns only, for the reason `textSearchFieldsOf` gives: a boolean\n // filter column outside `fts.fields` would otherwise be ILIKE'd here.\n const textFields = textSearchFieldsOf(entity, config.fields)\n const ilikeFields = textFields.filter((f) => !ftsFieldSet.has(f))\n // Translation-aware search (#543): translatable searchable fields are\n // matched via correlated EXISTS on `<entity>_translations` (their values\n // — and their tsvector — live there, not on the main table). The overlay\n // list carries ALL translatable entity fields so the locale's values are\n // merged onto raw FTS rows before the projection runs (raw selects bypass\n // findMany's locale merge — finding 2 on #543).\n const allFields = entity.allFields as Record<string, { translatable?: boolean }>\n const translatableSearchFields = filterTranslatableFields(allFields, textFields)\n const translationOverlayFields = filterTranslatableFields(allFields, Object.keys(allFields))\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 translatableFields: translatableSearchFields,\n translationOverlayFields,\n transform: adaptProjection(entity, config.projection, defaultLocale),\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 // Lets the provider skip the translation row-overlay for default-locale\n // searches (default-locale content lives on the base row by convention).\n if (defaultLocale !== undefined) {\n providerConfig.defaultLocale = defaultLocale\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":"oiBA6CA,SAAgB,EACd,EACA,EACA,EACA,EACgB,CA4BhB,MAAO,CA1BL,SAAU,EAAM,SAChB,aAAc,EAAM,aACpB,GAAI,EAAM,qBAAuB,IAAA,GAE7B,CAAC,EADD,CAAE,mBAAoB,EAAM,kBAAmB,EAEnD,MAAM,OAAO,EAAO,EAAQ,CAC1B,IAAM,EAAS,MAAM,EAAM,OAAO,EAAO,CAAM,EACzC,EAAS,EAAM,QAAU,EAC/B,GAAI,IAAW,IAAA,GAAW,OAAO,EAEjC,IAAM,EAAa,EAAO,KAAK,OAAQ,GAAM,EAAE,MAAQ,IAAA,EAAS,CAAC,CAAC,IAAK,GAAM,EAAE,EAAE,EACjF,GAAI,EAAW,SAAW,EAAG,OAAO,EAEpC,IAAM,EAAQ,MAAM,EAAQ,EAAY,EAAY,CAAM,EAG1D,OAFI,EAAM,OAAS,EAAU,EAEtB,CACL,GAAG,EACH,KAAM,EAAO,KAAK,IAAK,GAAM,CAC3B,GAAI,EAAE,MAAQ,IAAA,GAAW,OAAO,EAChC,IAAM,EAAO,EAAM,IAAI,EAAE,EAAE,EAC3B,OAAO,IAAS,IAAA,GAAkC,EAAtB,CAAE,GAAG,EAAG,IAAK,CAAK,CAChD,CAAC,CACH,CACF,CAEW,CACf,CCrCA,SAASA,EAAc,EAAoB,EAAmC,CAC5E,OAAO,EAAM,EACf,CAmDA,MAAM,EAAyC,CAC7C,SAAU,GACV,QAAS,GACT,OAAQ,GACR,MAAO,GACP,OAAQ,EACV,EAoBA,IAAa,EAAb,KAA2D,CACzD,SACA,mBACA,aAAwB,EAExB,OACA,iBACA,mBACA,mBACA,UAEA,YAAY,EAAmC,CAC7C,GAAI,EAAO,iBAAiB,SAAW,EACrC,MAAU,MAAM,kBAAkB,EAAO,SAAS,yCAAyC,EAM7F,IAAM,EAAQ,EAAO,OAAO,SAAS,EAC/B,EAAmB,EAAO,kBAAoB,EAAO,iBAC3D,IAAK,IAAM,IAAS,CAAC,GAAG,EAAO,iBAAkB,GAAG,CAAgB,EAClE,GAAIA,EAAc,EAAO,CAAK,IAAM,IAAA,GAClC,MAAU,MACR,kBAAkB,EAAO,SAAS,WAAW,EAAM,sCACrD,EAQJ,IAAM,EAAqB,EAAO,oBAAsB,CAAC,EACzD,GAAI,EAAmB,OAAS,EAAG,CACjC,IAAM,EAAoB,EAAO,OAAO,uBAAuB,GAAK,KACpE,GAAI,IAAsB,KACxB,MAAU,MACR,kBAAkB,EAAO,SAAS,uEACpC,EAEF,IAAK,IAAM,KAAS,EAClB,GAAIA,EAAc,EAAmB,CAAK,IAAM,IAAA,GAC9C,MAAU,MACR,kBAAkB,EAAO,SAAS,wBAAwB,EAAM,4CAClE,CAGN,CAEA,KAAK,SAAW,EAAO,SACnB,EAAO,qBAAuB,IAAA,KAChC,KAAK,mBAAqB,EAAO,oBAEnC,KAAK,OAAS,EAAO,OACrB,KAAK,iBAAmB,EAAO,iBAC/B,KAAK,mBAAqB,IAAI,IAAI,CAAgB,EAClD,KAAK,mBAAqB,EAC1B,KAAK,UAAY,EAAO,SAC1B,CAEA,MAAM,OAAO,EAAoB,EAA4C,CAC3E,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,OAAO,EAGvE,IAAM,EAAiB,KAAK,oBAAoB,CAAK,EAGrD,GAAI,IAAmB,KACrB,MAAO,CAAE,KAAM,CAAC,EAAG,MAAO,EAAG,OAAQ,CAAC,EAAG,WAAY,CAAE,EAOzD,IAAM,EAAW,EAAI,EAAgB,GAJZ,KAAK,sBAAsB,EAAM,OAIH,CAAC,EACxD,GAAI,IAAa,IAAA,GAAW,MAAU,MAAM,iDAAiD,EAC7F,IAAM,EAAa,EAEb,EAA+B,CACnC,QACA,MAAO,EAAM,MACb,OAAQ,EAAM,MAChB,EACI,EAAM,SAAW,IAAA,KAAW,EAAY,OAAS,EAAM,QAE3D,IAAM,EAAQ,KAAK,IAAI,EACjB,EAAO,MAAM,KAAK,OAAO,SAAS,CAAW,EACnD,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,OAAO,EAEvE,IAAM,EAAa,KAAK,IAAI,EAAI,EAE1B,EAAQ,MAAM,KAAK,OAAO,MAAM,CAAE,OAAM,CAAwB,EACtE,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,OAAO,EAGvE,MAAO,CACL,KAAM,EAAK,IAAK,GAAQ,KAAK,UAAU,EAAK,EAAM,MAAM,CAAC,EACzD,QACA,OAAQ,CAAC,EACT,YACF,CACF,CAWA,oBAA4B,EAAgC,CAC1D,IAAM,EAAQ,KAAK,OAAO,SAAS,EAC7B,EAAgB,CAAC,EACjB,EAAU,EAAa,EAAM,MAAO,EAAM,IAAI,EAEpD,IAAK,IAAM,KAAS,KAAK,iBAAkB,CACzC,IAAM,EAASA,EAAc,EAAO,CAAK,EACrC,IAAW,IAAA,KACX,EAAM,OAAS,OACjB,EAAO,KAAK,EAAG,EAAQ,EAAM,KAAK,CAAC,EAEnC,EAAO,KAAK,EAAM,EAAQ,CAAO,CAAC,EAEtC,CAEA,IAAM,EAAqB,KAAK,2BAA2B,EAAO,EAAO,CAAO,EAGhF,GAFI,IAAuB,IAAA,IAAW,EAAO,KAAK,CAAkB,EAEhE,EAAO,SAAW,EAAG,OAAO,KAChC,IAAM,EAAQ,EAAO,GACrB,GAAI,IAAU,IAAA,GAAW,OAAO,KAChC,GAAI,EAAO,SAAW,EAAG,OAAO,EAEhC,IAAM,EAAW,EAAG,GAAG,CAAM,EAC7B,GAAI,IAAa,IAAA,GAAW,MAAU,MAAM,wCAAwC,EACpF,OAAO,CACT,CAQA,2BACE,EACA,EACA,EACiB,CACjB,GAAI,KAAK,mBAAmB,SAAW,EAAG,OAC1C,IAAM,EAAoB,KAAK,OAAO,uBAAuB,GAAK,KAClE,GAAI,IAAsB,KAAM,OAChC,IAAM,EAAWA,EAAc,EAAO,IAAI,EAC1C,GAAI,IAAa,IAAA,GAAW,OAE5B,IAAM,EAA+D,CACnE,WACA,oBACA,OAAQ,KAAK,mBACb,eAAiB,GACf,EAAM,OAAS,OAAS,EAAG,EAAQ,EAAM,KAAK,EAAI,EAAM,EAAQ,CAAO,CAC3E,EAEA,OADI,EAAM,SAAW,IAAA,KAAW,EAAQ,OAAS,EAAM,QAChD,EAA8B,CAAO,CAC9C,CASA,sBAA8B,EAAwC,CACpE,IAAM,EAAQ,KAAK,OAAO,SAAS,EAC7B,EAAa,CAAC,EAEpB,IAAK,GAAM,CAAC,EAAO,KAAW,OAAO,QAAQ,CAAO,EAAG,CACrD,GAAI,CAAC,KAAK,mBAAmB,IAAI,CAAK,EAAG,SACzC,IAAM,EAASA,EAAc,EAAO,CAAK,EACrC,OAAW,IAAA,IACX,EAAO,SAAW,EACtB,GAAI,EAAO,SAAW,EAAG,CACvB,IAAM,EAAS,EAAO,GACtB,GAAI,IAAW,IAAA,GAAW,SAC1B,EAAI,KAAK,EAAG,EAAQ,CAAM,CAAC,CAC7B,MACE,EAAI,KAAK,EAAQ,EAAQ,CAAC,GAAG,CAAM,CAAC,CAAC,CAEzC,CAEA,OAAO,CACT,CACF,EAEA,SAAS,EAAa,EAAe,EAAmC,CACtE,IAAM,EAAU,EAAkB,CAAK,EAGvC,OAFI,IAAS,SAAiB,GAAG,EAAQ,GAElC,IAAI,EAAQ,EACrB,CClOA,SAAS,EAAc,EAAoB,EAAmC,CAC5E,OAAO,EAAM,EACf,CAGA,MAAa,EAA0B,cAG1B,EAA4B,SAUnC,EAAY,eACZ,EAAY,eAoIZ,EAA4C,CAChD,SAAU,GACV,QAAS,GACT,OAAQ,GACR,MAAO,GACP,OAAQ,EACV,EAeA,IAAa,EAAb,KAAwD,CACtD,SACA,mBACA,aAAwB,EAExB,OACA,aACA,SACA,mBACA,UACA,SACA,eACA,YACA,mBACA,yBACA,cAEA,YAAY,EAAgC,CAC1C,IAAM,EAAe,EAAO,cAAA,cACtB,EAAW,EAAO,UAAA,SAMxB,GAAI,EAAO,cAAgB,IAAA,IAAa,EAAO,cAAgB,SAC7D,MAAU,MACR,qBAAqB,EAAO,SAAS,yBAAyB,EAAO,YAAY,8NACnF,EAGF,IAAM,EAAQ,EAAO,OAAO,SAAS,EAKrC,GAAI,EAAc,EAAO,CAAW,IAAM,IAAA,GACxC,MAAU,MACR,qBAAqB,EAAO,SAAS,0JACvC,EAEF,GAAI,EAAc,EAAO,CAAY,IAAM,IAAA,GACzC,MAAU,MACR,qBAAqB,EAAO,SAAS,qBAAqB,EAAa,sCACzE,EAEF,IAAK,IAAM,KAAS,EAAO,iBACzB,GAAI,EAAc,EAAO,CAAK,IAAM,IAAA,GAClC,MAAU,MACR,qBAAqB,EAAO,SAAS,sBAAsB,EAAM,sCACnE,EAQJ,IAAK,IAAM,KAAS,OAAO,KAAK,EAAO,gBAAkB,CAAC,CAAC,EACzD,GAAI,EAAc,EAAO,CAAK,IAAM,IAAA,GAClC,MAAU,MACR,qBAAqB,EAAO,SAAS,0BAA0B,EAAM,sCACvE,EAMJ,IAAK,IAAM,KAAS,EAAO,aAAe,CAAC,EACzC,GAAI,EAAc,EAAO,CAAK,IAAM,IAAA,GAClC,MAAU,MACR,qBAAqB,EAAO,SAAS,uBAAuB,EAAM,sCACpE,EAQJ,IAAM,EAAqB,EAAO,oBAAsB,CAAC,EACnD,EAA2B,EAAO,0BAA4B,CAAC,EACrE,GAAI,EAAmB,OAAS,GAAK,EAAyB,OAAS,EAAG,CACxE,IAAM,EAAoB,EAAO,OAAO,uBAAuB,GAAK,KACpE,GAAI,IAAsB,KACxB,MAAU,MACR,qBAAqB,EAAO,SAAS,wEACvC,EAEF,IAAK,IAAM,IAAS,CAAC,GAAG,EAAoB,GAAG,CAAwB,EACrE,GAAI,EAAc,EAAmB,CAAK,IAAM,IAAA,GAC9C,MAAU,MACR,qBAAqB,EAAO,SAAS,wBAAwB,EAAM,4CACrE,CAGN,CAMA,IAAM,EAA8B,CAAC,EAC/B,EAAc,EAAO,UAAY,CAAC,EAClC,EAAkB,EAAO,iBAAmB,CAAC,EACnD,IAAK,IAAM,KAAO,EAAa,CAC7B,IAAM,EAAY,EAAgB,EAAI,QACtC,GAAI,CAAC,EACH,MAAU,MACR,qBAAqB,EAAO,SAAS,aAAa,EAAI,OAAO,kCAC/D,EAEF,IAAM,EAAW,EAAU,SAAS,EAGpC,GAAI,EAAc,EAAU,CAAW,IAAM,IAAA,GAC3C,MAAU,MACR,qBAAqB,EAAO,SAAS,aAAa,EAAI,OAAO,oFAC/D,EAEF,GAAI,EAAc,EAAU,CAAY,IAAM,IAAA,GAC5C,MAAU,MACR,qBAAqB,EAAO,SAAS,aAAa,EAAI,OAAO,gCAAgC,EAAa,8DAC5G,EAEF,GAAI,EAAc,EAAU,EAAI,EAAE,IAAM,IAAA,GACtC,MAAU,MACR,qBAAqB,EAAO,SAAS,aAAa,EAAI,OAAO,sBAAsB,EAAI,GAAG,EAC5F,EAEF,EAAS,KAAK,CAAE,OAAQ,EAAI,OAAQ,GAAI,EAAI,GAAI,OAAQ,CAAU,CAAC,CACrE,CAEA,KAAK,SAAW,EAAO,SACnB,EAAO,qBAAuB,IAAA,KAChC,KAAK,mBAAqB,EAAO,oBAEnC,KAAK,OAAS,EAAO,OACrB,KAAK,aAAe,EACpB,KAAK,SAAW,EAChB,KAAK,mBAAqB,IAAI,IAAI,EAAO,gBAAgB,EACzD,KAAK,UAAY,EAAO,UACxB,KAAK,SAAW,EAChB,KAAK,eAAiB,EAAO,gBAAkB,CAAC,EAChD,KAAK,YAAc,EAAO,aAAe,CAAC,EAC1C,KAAK,mBAAqB,EAC1B,KAAK,yBAA2B,EAChC,KAAK,cAAgB,EAAO,aAC9B,CAEA,MAAM,OAAO,EAAoB,EAA4C,CAC3E,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,OAAO,EAEvE,IAAM,EACJ,KAAK,SAAS,SAAW,EACrB,MAAM,KAAK,mBAAmB,CAAK,EACnC,MAAM,KAAK,mBAAmB,CAAK,EACzC,GAAI,EAAO,QACT,MAAM,IAAI,EAAY,iBAAkB,IAAK,EAAiB,OAAO,EAEvE,OAAO,CACT,CAOA,MAAc,mBAAmB,EAA2C,CAC1E,IAAM,EAAQ,KAAK,OAAO,SAAS,EAC7B,EAAS,KAAK,cAAc,CAAK,EACjC,EAAQ,EAAc,EAAO,IAAI,EACvC,GAAI,IAAU,IAAA,GACZ,MAAU,MAAM,qBAAqB,KAAK,SAAS,wCAAwC,EAG7F,IAAM,EAAc,EAAa,EAAM,MAAO,EAAM,KAAM,KAAK,QAAQ,EACjE,EAAW,CAAW,cAAc,EAAO,IAAI,EAAY,GAK3D,EAAY,KAAK,eAAe,EAAO,EAAQ,EAAa,EAAM,MAAO,EAAM,MAAM,EAErF,EAAmB,KAAK,yBAAyB,EAAM,QAAS,CAAK,EACrE,EACJ,EAAiB,SAAW,EAAI,EAAY,EAAI,EAAW,GAAG,CAAgB,EAChF,GAAI,IAAc,IAAA,GAChB,MAAU,MAAM,iDAAiD,EAGnE,IAAM,EAAe,EACf,EAAQ,KAAK,IAAI,EACjB,EAAO,MAAM,KAAK,OACrB,MAAM,CAAC,CACP,OAAO,CACN,GAAG,EAAgB,CAAY,GAC9B,GAAY,GACZ,GAAY,CAAW,uBAC1B,CAAC,CAAC,CACD,KAAK,CAAY,CAAC,CAClB,MAAM,CAAS,CAAC,CAChB,QAAQ,EAAK,CAAQ,EAAG,EAAI,CAAK,CAAC,CAAC,CACnC,MAAM,EAAM,KAAK,CAAC,CAClB,OAAO,EAAM,MAAM,EAEhB,EAAS,MAAM,KAAK,oBAAoB,EAAM,EAAM,MAAM,EAC1D,EAAa,KAAK,IAAI,EAAI,EAChC,OAAO,KAAK,YAAY,EAAQ,EAAY,EAAM,MAAM,CAC1D,CAYA,MAAc,mBAAmB,EAA2C,CAC1E,IAAM,EAAY,KAAK,OAAO,SAAS,EACjC,EAAa,KAAK,cAAc,CAAS,EACzC,EAAY,EAAc,EAAW,IAAI,EAC/C,GAAI,IAAc,IAAA,GAChB,MAAU,MAAM,qBAAqB,KAAK,SAAS,wCAAwC,EAG7F,IAAM,EAAc,EAAa,EAAM,MAAO,EAAM,KAAM,KAAK,QAAQ,EAQjE,EAAa,KAAK,OACrB,MAAM,CAAC,CACP,OAAO,CACN,UAAW,CAAW,GAAG,IAAY,GAAG,WAAW,EACnD,MAAO,CAAW,cAAc,EAAW,IAAI,EAAY,GAAG,GAAG,OAAO,CAC1E,CAAC,CAAC,CACD,KAAK,CAA+B,CAAC,CACrC,MAAM,KAAK,eAAe,EAAW,EAAY,EAAa,EAAM,MAAO,EAAM,MAAM,CAAC,EAErF,EAAiB,KAAK,SAAS,IAAK,GAAQ,CAChD,IAAM,EAAW,EAAI,OAAO,SAAS,EAC/B,EAAY,KAAK,cAAc,CAAQ,EACvC,EAAQ,EAAc,EAAU,EAAI,EAAE,EAC5C,GAAI,IAAU,IAAA,GACZ,MAAU,MACR,qBAAqB,KAAK,SAAS,aAAa,EAAI,OAAO,oBAAoB,EAAI,GAAG,kCACxF,EAYF,OAAO,EAAI,OACR,MAAM,CAAC,CACP,OAAO,CACN,UAAW,CAAW,GAAG,IAAQ,GAAG,WAAW,EAC/C,MAAO,CAAW,kBAAkB,EAAU,IAAI,EAAY,IAAI,GAAG,OAAO,CAC9E,CAAC,CAAC,CACD,KAAK,CAA8B,CAAC,CACpC,WAIa,CACV,IAAM,EAAQ,CAAG,GAAG,EAAU,MAAM,IAC9B,EAAU,KAAK,iBAAiB,CAAQ,EAC9C,OAAO,EAAW,EAAI,EAAO,CAAO,GAAK,EAAS,CACpD,EAAA,CAAG,CACL,CAAC,CACA,QAAQ,CAAG,GAAG,GAAO,CAC1B,CAAC,EAOK,EAAe,EAAe,GACpC,GAAI,CAAC,EAEH,MAAU,MACR,qBAAqB,KAAK,SAAS,iDACrC,EAEF,IAAM,EAAY,EAAS,EAAY,EAAc,GAAG,EAAe,MAAM,CAAC,CAAC,CAAC,CAAC,GAC/E,YACF,EAGM,EAAkB,KAAK,OAC1B,MAAM,CAAC,CACP,OAAO,CACN,UAAW,CAAW,GAAG,EAAU,YAAY,GAAG,WAAW,EAC7D,MAAO,CAAW,OAAO,EAAU,MAAM,GAAG,GAAG,OAAO,CACxD,CAAC,CAAC,CACD,KAAK,CAAS,CAAC,CACf,QAAQ,EAAU,SAAS,CAAC,CAC5B,GAAG,GAAG,EAIH,EAAe,KAAK,yBAAyB,EAAM,QAAS,CAAS,EACrE,EAAc,EACd,EAAQ,KAAK,IAAI,EAEjB,EAAa,KAAK,OACrB,MAAM,CAAC,CACP,OAAO,CACN,GAAG,EAAgB,CAAW,GAC7B,GAAY,CAAW,GAAG,EAAgB,SAM1C,GAAY,CAAW,uBAC1B,CAAC,CAAC,CACD,KAAK,CAAe,CAAC,CACrB,UAAU,EAAa,EAAG,EAAgB,UAAW,CAAS,CAAC,EAS5D,EAAO,MANX,EAAa,SAAW,EAAI,EAAa,EAAW,MAAM,EAAI,GAAG,CAAY,CAAC,EAAA,CAE7E,QAAQ,EAAK,EAAgB,KAAK,EAAG,EAAI,CAAS,CAAC,CAAC,CACpD,MAAM,EAAM,KAAK,CAAC,CAClB,OAAO,EAAM,MAES,EACnB,EAAS,MAAM,KAAK,oBAAoB,EAAM,EAAM,MAAM,EAC1D,EAAa,KAAK,IAAI,EAAI,EAChC,OAAO,KAAK,YAAY,EAAQ,EAAY,EAAM,MAAM,CAC1D,CAEA,cAAsB,EAA4B,CAChD,IAAM,EAAM,EAAc,EAAO,KAAK,YAAY,EAClD,GAAI,IAAQ,IAAA,GACV,MAAU,MACR,qBAAqB,KAAK,SAAS,qBAAqB,KAAK,aAAa,yBAC5E,EAEF,OAAO,CACT,CAaA,eACE,EACA,EACA,EACA,EACA,EACK,CACL,IAAM,EAAU,CAAG,GAAG,EAAO,MAAM,IAC7B,EAAU,IAAI,EAAkB,CAAQ,EAAE,GAC1C,EAAgB,CAAC,CAAO,EAE9B,IAAK,IAAM,KAAa,KAAK,YAAa,CACxC,IAAM,EAAS,EAAc,EAAO,CAAS,EACzC,IAAW,IAAA,IACf,EAAO,KAAK,EAAM,EAAQ,CAAO,CAAC,CACpC,CAEA,IAAM,EAAqB,KAAK,2BAA2B,EAAO,EAAS,CAAM,EAKjF,OAJI,IAAuB,IAAA,IAAW,EAAO,KAAK,CAAkB,EAEhE,EAAO,SAAW,EAAU,EACf,EAAG,GAAG,CACT,GAAK,CACrB,CAaA,2BACE,EACA,EACA,EACiB,CACjB,GAAI,KAAK,mBAAmB,SAAW,EAAG,OAC1C,IAAM,EAAoB,KAAK,OAAO,uBAAuB,GAAK,KAClE,GAAI,IAAsB,KAAM,OAChC,IAAM,EAAW,EAAc,EAAO,IAAI,EAC1C,GAAI,IAAa,IAAA,GAAW,OAE5B,IAAM,EAA+D,CACnE,WACA,oBACA,OAAQ,KAAK,mBACb,eAAiB,GAAW,EAAM,EAAQ,CAAO,CACnD,EAEA,OADI,IAAW,IAAA,KAAW,EAAQ,OAAS,GACpC,EAA8B,CAAO,CAC9C,CAWA,MAAc,oBACZ,EACA,EACoC,CAMpC,GALI,IAAW,IAAA,IAAa,EAAK,SAAW,GAIxC,IAAW,KAAK,eAChB,KAAK,yBAAyB,SAAW,EAAG,OAAO,EACvD,IAAM,EAAoB,KAAK,OAAO,uBAAuB,GAAK,KAClE,GAAI,IAAsB,KAAM,OAAO,EACvC,IAAM,EAAc,EAAc,EAAmB,UAAU,EACzD,EAAY,EAAc,EAAmB,QAAQ,EAC3D,GAAI,IAAgB,IAAA,IAAa,IAAc,IAAA,GAAW,OAAO,EAEjE,IAAM,EAAM,EAAK,IAAK,GAAQ,EAAI,EAAE,CAAC,CAAC,OAAQ,GAAqB,OAAO,GAAO,QAAQ,EACzF,GAAI,EAAI,SAAW,EAAG,OAAO,EAE7B,IAAM,EAAe,MAAM,KAAK,OAC7B,MAAM,CAAC,CACP,OAAO,CAAC,CACR,KAAK,CAAuC,CAAC,CAC7C,MAAM,EAAI,EAAQ,EAAa,CAAG,EAAG,EAAG,EAAW,CAAM,CAAC,CAAC,EAExD,EAAoB,IAAI,IAC9B,IAAK,IAAM,KAAe,EAA2C,CACnE,IAAM,EAAmC,CAAC,EAC1C,IAAK,IAAM,KAAS,KAAK,yBAA0B,CACjD,IAAM,EAAQ,EAAY,GACtB,GAAiC,OAAM,EAAQ,GAAS,EAC9D,CACA,EAAkB,IAAI,EAAY,SAAU,CAAO,CACrD,CAEA,OAAO,EAAK,IAAK,GAAQ,CACvB,IAAM,EAAU,EAAkB,IAAI,EAAI,EAAE,EAC5C,OAAO,EAAU,CAAE,GAAG,EAAK,GAAG,CAAQ,EAAI,CAC5C,CAAC,CACH,CAEA,YACE,EACA,EACA,EACc,CACd,IAAM,EAAQ,EAAU,CAAI,EAM5B,MAAO,CAAE,KALU,EAAK,IAAK,GAAQ,CACnC,IAAM,EAAY,KAAK,UAAU,EAAmB,CAAG,EAAG,CAAM,EAC1D,EAAQ,EAAU,CAAG,EAC3B,OAAO,IAAU,IAAA,GAAsC,EAA1B,CAAE,GAAG,EAAW,OAAM,CACrD,CACwB,EAAG,QAAO,OAAQ,CAAC,EAAG,YAAW,CAC3D,CAcA,yBACE,EACA,EACO,CACP,IAAM,EAAa,CAAC,EACpB,EAAI,KAAK,GAAG,KAAK,sBAAsB,EAAc,CAAK,CAAC,EAC3D,IAAM,EAAiB,IAAI,IAAI,OAAO,KAAK,CAAY,CAAC,EACxD,IAAK,GAAM,CAAC,EAAO,KAAW,OAAO,QAAQ,KAAK,cAAc,EAAG,CACjE,GAAI,EAAe,IAAI,CAAK,EAAG,SAC/B,IAAM,EAAY,KAAK,2BAA2B,EAAO,EAAQ,CAAK,EAClE,IAAc,IAAA,IAAW,EAAI,KAAK,CAAS,CACjD,CAMA,IAAM,EAAU,KAAK,iBAAiB,CAAK,EAE3C,OADI,GAAS,EAAI,KAAK,CAAO,EACtB,CACT,CAOA,iBAAyB,EAAqC,CAC5D,IAAM,EAAM,EAAc,EAAO,WAAW,EAC5C,OAAO,EAAM,EAAO,CAAG,EAAI,IAAA,EAC7B,CAQA,2BACE,EACA,EACA,EACiB,CACjB,IAAM,EAAS,EAAc,EAAO,CAAK,EACrC,OAAW,IAAA,IACX,EAAO,SAAW,EACtB,IAAI,EAAO,SAAW,EAAG,CACvB,IAAM,EAAS,EAAO,GAEtB,OADI,IAAW,IAAA,GAAW,OACnB,EAAG,EAAQ,CAAM,CAC1B,CACA,OAAO,EAAQ,EAAQ,CAAC,GAAG,CAAM,CAAC,CADlC,CAEF,CAOA,sBAA8B,EAAiC,EAA2B,CACxF,IAAM,EAAa,CAAC,EACpB,IAAK,GAAM,CAAC,EAAO,KAAW,OAAO,QAAQ,CAAO,EAAG,CACrD,GAAI,CAAC,KAAK,mBAAmB,IAAI,CAAK,EAAG,SACzC,IAAM,EAAS,EAAc,EAAO,CAAK,EACrC,OAAW,IAAA,IACX,EAAO,SAAW,EACtB,GAAI,EAAO,SAAW,EAAG,CACvB,IAAM,EAAS,EAAO,GACtB,GAAI,IAAW,IAAA,GAAW,SAC1B,EAAI,KAAK,EAAG,EAAQ,CAAM,CAAC,CAC7B,MACE,EAAI,KAAK,EAAQ,EAAQ,CAAC,GAAG,CAAM,CAAC,CAAC,CAEzC,CACA,OAAO,CACT,CACF,EAoBA,SAAgB,EAAa,EAAe,EAA2B,EAAuB,CAC5F,OAAQ,EAAR,CACE,IAAK,SAAU,CAeb,IAAM,EAAS,EACZ,MAAM,KAAK,CAAC,CACZ,IAAK,GAAM,EAAE,QAAQ,qBAAsB,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CACtD,OAAQ,GAAM,EAAE,OAAS,CAAC,EAQ7B,OAPI,EAAO,SAAW,EAIb,CAAG,cAAc,EAAS,IAAI,iBAAiB,GAGjD,CAAG,cAAc,EAAS,IADb,EAAO,IAAK,GAAM,GAAG,EAAE,GAAG,CAAC,CAAC,KAAK,KACN,EAAE,EACnD,CACA,IAAK,SACH,MAAO,EAAG,oBAAoB,EAAS,IAAI,EAAM,GACnD,QACE,MAAO,EAAG,wBAAwB,EAAS,IAAI,EAAM,EACzD,CACF,CAWA,SAAS,EAAU,EAAsD,CACvE,GAAI,EAAK,SAAW,EAAG,MAAO,GAC9B,IAAM,EAAQ,EAAK,GACnB,GAAI,CAAC,EAAO,MAAO,GACnB,IAAM,EAAQ,EAAM,GACpB,GAAI,OAAO,GAAU,SAAU,OAAO,EACtC,GAAI,OAAO,GAAU,SAAU,CAC7B,IAAM,EAAS,OAAO,CAAK,EAC3B,OAAO,OAAO,SAAS,CAAM,EAAI,EAAS,CAC5C,CACA,MAAO,EACT,CAOA,SAAS,EAAmB,EAAuD,CACjF,IAAM,EAAgC,CAAC,EACvC,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,CAAG,EACvC,IAAQ,GAAa,IAAQ,IACjC,EAAK,GAAO,GAEd,OAAO,CACT,CAGA,SAAS,EAAU,EAAkD,CACnE,IAAM,EAAQ,EAAI,GAClB,GAAI,OAAO,GAAU,SAAU,OAAO,EACtC,GAAI,OAAO,GAAU,SAAU,CAC7B,IAAM,EAAS,OAAO,CAAK,EAC3B,OAAO,OAAO,SAAS,CAAM,EAAI,EAAS,IAAA,EAC5C,CAEF,CC10BA,SAAgB,EACd,EACA,EACA,EACoE,CACpE,IAAM,EAAO,EAAO,UAAU,KAC9B,OAAQ,EAAK,IAAW,CACtB,IAAM,EAAS,EAAW,CAAG,EAQvB,EAAY,GAAU,EAC5B,GAAI,EAAO,MAAQ,IAAA,IAAa,IAAS,IAAA,IAAa,EAAW,CAC/D,IAAM,EAAO,EAAgB,EAAM,EAAK,EAAW,CAAa,EAChE,GAAI,IAAS,KAAM,MAAO,CAAE,GAAG,EAAQ,IAAK,CAAK,CACnD,CACA,OAAO,CACT,CACF,CAWA,SAAgB,EAAqB,EAAwD,CAC3F,IAAM,EAAe,EAAQ,cAAgB,EACvC,EAA8B,CAAC,EAG/B,EAAqB,IAAI,IAS/B,IAAK,IAAM,KAAU,EAAQ,SAAU,CACrC,IAAM,EAAS,EAAoB,CAAM,EACzC,GAAI,CAAC,EAAQ,SACb,IAAM,EAAS,EAAa,CAAM,EAClC,EAAmB,IAAI,EAAO,KAAM,CAAE,SAAQ,SAAQ,QAAO,CAAC,CAChE,CAGA,IAAK,GAAM,CAAE,SAAQ,SAAQ,YAAY,EAAmB,OAAO,EAAG,CAepE,IAAM,EACJ,EAAY,CAAM,IACjB,EAAO,UAAY,CAAC,EAAA,CAAG,KAAM,GAAQ,CACpC,IAAM,EAAS,EAAmB,IAAI,EAAI,MAAM,EAChD,OAAO,IAAW,IAAA,IAAa,EAAY,EAAO,MAAM,CAC1D,CAAC,EAKH,GACE,GACA,EAAO,OACL,EAAO,UAAU,QAAU,GAAK,GAAK,EAAO,iBAAmB,IAAA,IAEjE,MAAU,MACR,oBAAoB,EAAO,KAAK,2QAIlC,EAEF,IAAM,EACJ,EAAO,KAAO,EAA2B,EAAQ,CAAM,GAAK,CAAC,EACzD,EAAsB,EAAQ,EAAQ,EAAQ,EAAoB,EAAQ,aAAa,EACvF,EAAmB,EAAQ,EAAQ,EAAQ,EAAQ,aAAa,EACtE,GAAI,IAAS,KAAM,CAMjB,QAAQ,KACN,sBAAsB,EAAO,KAAK,2GACpC,EACA,QACF,CAKA,IAAM,EACJ,EAAQ,wBAA0B,IAAA,IAAa,EAAO,UAAU,OAAS,IAAA,GACrE,EAAkB,EAAM,EAAO,KAAM,EAAQ,sBAAuB,EAAQ,aAAa,EACzF,EAGA,EACJ,EAAO,SAAW,SACjB,EAAO,UAAY,CAAC,EAAA,CAAG,KACrB,GAAQ,EAAQ,SAAS,KAAM,GAAM,EAAE,OAAS,EAAI,MAAM,CAAC,EAAE,SAAW,MAC3E,EACF,EAAU,KAAK,EAAgB,EAAW,CAAQ,EAAI,CAAQ,CAChE,CAQA,OAFI,EAAQ,gBAAgB,EAAU,KAAK,GAAG,EAAQ,cAAc,EAE7D,CACT,CAOA,SAAS,EAAW,EAA0C,CAC5D,MAAO,CACL,SAAU,EAAS,SACnB,GAAI,EAAS,qBAAuB,IAAA,IAAa,CAC/C,mBAAoB,EAAS,kBAC/B,EACA,aAAc,EAAS,aACvB,OAAQ,OACR,QAAS,EAAO,IAAW,EAAS,OAAO,EAAO,CAAM,CAC1D,CACF,CAGA,MAAM,EAAyC,IAAI,IAAI,CAAC,OAAQ,WAAY,OAAQ,QAAQ,CAAC,EAS7F,SAAS,EAAmB,EAAgB,EAAqC,CAC/E,IAAM,EAAW,EAAO,UACxB,OAAO,EAAO,OACX,GAAU,OAAO,OAAO,EAAU,CAAK,GAAK,EAAkB,IAAI,EAAS,EAAM,EAAE,MAAQ,EAAE,CAChG,CACF,CAOA,SAAS,EACP,EACA,EACA,EACA,EACuB,CAGvB,IAAM,EAAY,EAeZ,EAAa,EAAmB,EAAQ,EAAO,MAAM,EAE3D,OADI,EAAW,SAAW,EAAU,KAC7B,IAAI,EAAuC,CAChD,SAAU,EAAO,KACjB,OAAQ,EACR,iBAAkB,EAClB,iBAAkB,CAAC,GAAG,EAAO,MAAM,EAMnC,mBAAoB,EAClB,EAAO,UACP,CACF,EACA,UAAW,EAAgB,EAAQ,EAAO,WAAY,CAAa,CACrE,CAAC,CACH,CAQA,SAAS,EACP,EACA,EACA,EACA,EAQA,EACgB,CAChB,IAAM,EAA2D,CAAC,EAClE,IAAK,IAAM,KAAO,EAAO,UAAY,CAAC,EAAG,CACvC,IAAM,EAAS,EAAmB,IAAI,EAAI,MAAM,EAChD,GAAI,CAAC,EACH,MAAU,MACR,2BAA2B,EAAO,KAAK,cAAc,EAAI,OAAO,UAAU,EAAI,OAAO,mGACvF,EAEF,GAAI,CAAC,EAAO,OAAO,IACjB,MAAU,MACR,2BAA2B,EAAO,KAAK,cAAc,EAAI,OAAO,UAAU,EAAI,OAAO,mHACvF,EAEF,EAAgB,EAAI,QAAU,EAAO,MACvC,CASA,IAAM,EAAc,EAAO,IAAM,IAAI,IAAI,EAAO,IAAI,MAAM,EAAI,IAAI,IAG5D,EAAa,EAAmB,EAAQ,EAAO,MAAM,EACrD,EAAc,EAAW,OAAQ,GAAM,CAAC,EAAY,IAAI,CAAC,CAAC,EAO1D,EAAY,EAAO,UACnB,EAA2B,EAAyB,EAAW,CAAU,EACzE,EAA2B,EAAyB,EAAW,OAAO,KAAK,CAAS,CAAC,EACrF,EAAoE,CACxE,SAAU,EAAO,KACT,SACR,iBAAkB,CAAC,GAAG,EAAO,MAAM,EACnC,cACA,mBAAoB,EACpB,2BACA,UAAW,EAAgB,EAAQ,EAAO,WAAY,CAAa,CACrE,EAyBA,OArBI,EAAO,QAAU,IAAA,KACnB,EAAe,YAAc,EAAO,OAIlC,IAAkB,IAAA,KACpB,EAAe,cAAgB,GAE7B,EAAO,KAAK,WAAa,IAAA,KAC3B,EAAe,SAAW,EAAO,IAAI,UAEnC,EAAO,UAAY,EAAO,SAAS,OAAS,IAC9C,EAAe,SAAW,EAAO,SAAS,IAAK,IAAS,CACtD,OAAQ,EAAI,OACZ,GAAI,EAAI,EACV,EAAE,EACF,EAAe,gBAAkB,GAE/B,EAAO,iBAAmB,IAAA,KAC5B,EAAe,eAAiB,EAAO,gBAElC,IAAI,EAAiB,CAAc,CAC5C,CAeA,SAAS,EAA2B,EAAgB,EAAmC,CACrF,GAAI,CAAC,EAAO,IAAK,MAAO,GACxB,IAAM,EAAY,EAAO,UACzB,IAAK,IAAM,KAAa,EAAO,IAAI,OAAQ,CACzC,IAAM,EAAc,EAAU,GACzB,MACD,CAAC,EAAY,aAAc,MAAO,EACxC,CACA,MAAO,EACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/search-postgres",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
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.84.0",
18
- "@murumets-ee/entity": "0.84.0",
19
- "@murumets-ee/search": "0.84.0"
17
+ "@murumets-ee/core": "0.85.0",
18
+ "@murumets-ee/entity": "0.85.0",
19
+ "@murumets-ee/search": "0.85.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^20.19.42",