@@ -835,6 +835,191 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
835835 } ;
836836 }
837837
838+ // ==========================================
839+ // Global Search (M10.5)
840+ // ==========================================
841+ /**
842+ * Cross-object substring search across all registered objects that opt in
843+ * via `enable.searchable !== false` and `enable.apiEnabled !== false`.
844+ * Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
845+ * whose `searchable: true` flag is set, falling back to the object's
846+ * `displayNameField` (or `name`) when no fields are explicitly searchable.
847+ *
848+ * The query is split into whitespace-separated terms; each term must match
849+ * (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
850+ * enforced by forwarding the caller's `context` to `engine.find` so users
851+ * only see records they are entitled to read.
852+ */
853+ async searchAll ( request : {
854+ q : string ;
855+ objects ?: string [ ] ;
856+ limit ?: number ;
857+ perObject ?: number ;
858+ context ?: any ;
859+ } ) : Promise < {
860+ query : string ;
861+ hits : Array < {
862+ object : string ;
863+ id : string ;
864+ title : string ;
865+ snippet ?: string ;
866+ record : any ;
867+ } > ;
868+ totalObjects : number ;
869+ totalHits : number ;
870+ truncated : boolean ;
871+ } > {
872+ const q = ( request . q ?? '' ) . trim ( ) ;
873+ if ( ! q ) {
874+ return { query : '' , hits : [ ] , totalObjects : 0 , totalHits : 0 , truncated : false } ;
875+ }
876+
877+ const overallLimit = Math . max ( 1 , Math . min ( 100 , Number ( request . limit ?? 20 ) ) ) ;
878+ const perObject = Math . max ( 1 , Math . min ( 25 , Number ( request . perObject ?? 5 ) ) ) ;
879+ const objectsFilter = request . objects && request . objects . length
880+ ? new Set ( request . objects )
881+ : null ;
882+
883+ // Tokenise: each token must match (LIKE %term%) at least one searchable field
884+ const terms = q . split ( / \s + / ) . filter ( Boolean ) . slice ( 0 , 8 ) ;
885+
886+ const allObjects = ( this . engine as any ) . registry ?. getAllObjects ?.( ) ?? [ ] ;
887+ const hits : Array < { object : string ; id : string ; title : string ; snippet ?: string ; record : any } > = [ ] ;
888+ let objectsScanned = 0 ;
889+
890+ for ( const obj of allObjects ) {
891+ if ( hits . length >= overallLimit ) break ;
892+ if ( ! obj ?. name ) continue ;
893+ if ( objectsFilter && ! objectsFilter . has ( obj . name ) ) continue ;
894+
895+ // Skip platform/system tables and opt-outs
896+ const enable = obj . enable ?? { } ;
897+ if ( enable . searchable === false ) continue ;
898+ if ( enable . apiEnabled === false ) continue ;
899+ // Skip noisy system tables by name prefix
900+ if ( obj . name . startsWith ( 'sys_audit_log' )
901+ || obj . name . startsWith ( 'sys_activity' )
902+ || obj . name . startsWith ( 'sys_session' )
903+ || obj . name . startsWith ( 'sys_presence' )
904+ || obj . name . startsWith ( 'sys_metadata' )
905+ || obj . name . startsWith ( 'sys_account' ) ) {
906+ continue ;
907+ }
908+
909+ const fieldsRaw = obj . fields ;
910+ const fields : Array < { name : string ; type : string ; searchable ?: boolean } > =
911+ Array . isArray ( fieldsRaw )
912+ ? fieldsRaw
913+ : ( fieldsRaw && typeof fieldsRaw === 'object'
914+ ? Object . entries ( fieldsRaw ) . map ( ( [ name , f ] : [ string , any ] ) => ( { name, ...( f || { } ) } ) )
915+ : [ ] ) ;
916+ const TEXT_TYPES = new Set ( [ 'text' , 'textarea' , 'string' , 'email' , 'url' , 'phone' , 'markdown' , 'html' ] ) ;
917+ const fieldByName = new Map ( fields . map ( f => [ f . name , f ] ) ) ;
918+ const hasField = ( n : string ) => fieldByName . has ( n ) ;
919+ // Resolve title for a record using titleFormat → displayNameField →
920+ // common conventional fields → id. titleFormat supports simple
921+ // `{field}` placeholders (the `template` dialect); unresolved
922+ // placeholders fall through to the next strategy.
923+ const titleFormatSource = ( obj . titleFormat && ( obj . titleFormat . source || obj . titleFormat ) )
924+ || undefined ;
925+ const renderTitle = ( row : any ) : string => {
926+ if ( typeof titleFormatSource === 'string' ) {
927+ let allResolved = true ;
928+ const rendered = titleFormatSource . replace ( / \{ \{ ? \s * ( [ a - z A - Z 0 - 9 _ . ] + ) \s * \} ? \} / g, ( _m , key ) => {
929+ const v = row [ key ] ;
930+ if ( v == null || v === '' ) { allResolved = false ; return '' ; }
931+ return String ( v ) ;
932+ } ) . trim ( ) ;
933+ if ( rendered && allResolved ) return rendered ;
934+ if ( rendered ) return rendered . replace ( / \s + - \s + $ / , '' ) . replace ( / ^ \s + - \s + / , '' ) . trim ( ) || row . id ;
935+ }
936+ const candidates = [
937+ obj . displayNameField ,
938+ 'name' , 'full_name' , 'title' , 'subject' , 'label' , 'company' ,
939+ ] . filter ( ( c ) : c is string => typeof c === 'string' && hasField ( c ) ) ;
940+ for ( const c of candidates ) {
941+ const v = row [ c ] ;
942+ if ( v != null && String ( v ) . trim ( ) ) return String ( v ) ;
943+ }
944+ const fn = row . first_name , ln = row . last_name ;
945+ if ( fn || ln ) return `${ fn ?? '' } ${ ln ?? '' } ` . trim ( ) ;
946+ return String ( row . id ) ;
947+ } ;
948+
949+ const titleFieldName = obj . displayNameField
950+ || ( hasField ( 'name' ) ? 'name' : undefined )
951+ || ( hasField ( 'title' ) ? 'title' : undefined )
952+ || fields . find ( f => TEXT_TYPES . has ( f . type ) ) ?. name ;
953+
954+ let searchableFields = fields
955+ . filter ( f => f && TEXT_TYPES . has ( f . type ) && f . searchable === true )
956+ . map ( f => f . name as string ) ;
957+
958+ // Fallback: if no field is explicitly searchable, scan the title field
959+ if ( searchableFields . length === 0 && titleFieldName ) {
960+ searchableFields = [ titleFieldName ] ;
961+ }
962+ if ( searchableFields . length === 0 ) continue ;
963+
964+ objectsScanned ++ ;
965+
966+ // Build AND-of-OR filter: every term must hit at least one field.
967+ // ObjectQL exposes case-insensitive substring matching via `$contains`.
968+ const andClauses = terms . map ( term => ( {
969+ $or : searchableFields . map ( f => ( { [ f ] : { $contains : term } } ) ) ,
970+ } ) ) ;
971+ const where = andClauses . length === 1 ? andClauses [ 0 ] : { $and : andClauses } ;
972+
973+ try {
974+ const opts : any = {
975+ where,
976+ limit : perObject ,
977+ orderBy : [ { field : 'updated_at' , direction : 'desc' } ] ,
978+ } ;
979+ if ( request . context !== undefined ) opts . context = request . context ;
980+
981+ const rows = await this . engine . find ( obj . name , opts ) ;
982+ for ( const row of rows || [ ] ) {
983+ if ( hits . length >= overallLimit ) break ;
984+ const title = renderTitle ( row ) ;
985+ // Build snippet from first searchable field that contains a term
986+ let snippet : string | undefined ;
987+ for ( const f of searchableFields ) {
988+ const v = row [ f ] ;
989+ if ( typeof v === 'string' && v ) {
990+ const lc = v . toLowerCase ( ) ;
991+ const idx = terms . map ( t => lc . indexOf ( t . toLowerCase ( ) ) ) . find ( i => i >= 0 ) ;
992+ if ( idx != null && idx >= 0 ) {
993+ const start = Math . max ( 0 , idx - 30 ) ;
994+ const end = Math . min ( v . length , idx + 90 ) ;
995+ snippet = ( start > 0 ? '…' : '' ) + v . slice ( start , end ) + ( end < v . length ? '…' : '' ) ;
996+ break ;
997+ }
998+ }
999+ }
1000+ hits . push ( {
1001+ object : obj . name ,
1002+ id : row . id ,
1003+ title,
1004+ snippet,
1005+ record : row ,
1006+ } ) ;
1007+ }
1008+ } catch {
1009+ // RBAC denial or driver hiccup — skip silently per object
1010+ continue ;
1011+ }
1012+ }
1013+
1014+ return {
1015+ query : q ,
1016+ hits,
1017+ totalObjects : objectsScanned ,
1018+ totalHits : hits . length ,
1019+ truncated : hits . length >= overallLimit ,
1020+ } ;
1021+ }
1022+
8381023 // ==========================================
8391024 // Metadata Caching
8401025 // ==========================================
0 commit comments