Skip to content

Search

Genie ships a global search that spans two things at once: the user’s navigation (jump to a page) and the indexable entities (find a record). Results come back grouped and ranked by object type. All scoping is resolved server-side from the authenticated session — a user only ever sees their own tenant’s rows and the pages they may open.

SearchService merges two sources:

  1. Navigation — the current user’s permission-filtered navbar (see Navigation); each navigable leaf is scored against the term so a page match ranks slightly above data matches on a tie.
  2. Searchable entities — every entity that declares an enabled <Search> block, queried through the active provider.

A term shorter than 2 characters returns nothing. Results are limited overall and per group, with a Truncated flag when trimmed.

Add a <Search> block to the entity model and mark the columns to match with Searchable="true". See Entities → Search for the full contract.

<Entity Name="Customer" SchemaName="Sales" PluralName="Customers">
<Fields>
<String Name="Name" Searchable="true" />
<String Name="Email" Searchable="true" />
</Fields>
<Search UrlTemplate="/view/Customer?Id={Id}"
Filter="IsActive = 1 AND CompanyId = @SessionCompanyId">
<Context Field="Name" />
</Search>
</Entity>
  • Searchable="true" columns are the ones matched against the term (falling back to the <Context> fields, then all fields, if none are marked).
  • <Context> fields are returned with each hit so the UI can render a meaningful result row.
  • UrlTemplate builds the link the result navigates to; Filter scopes which rows are eligible and can use session parameters (@SessionCompanyId, @SessionUserId, …).
  • ObjectType (optional) labels the result group; it defaults to the entity name.

SearchableEntityRegistry reads these declarations from the entity store at query time and projects them into ready-to-query descriptors.

Search resolves through one provider, chosen per deployment (ISearchProvider):

  • SqlSearchProvider (default) — generates a dialect-appropriate case-insensitive substring query (LIKE/ILIKE) over the searchable columns at runtime; the term is always a bound parameter. Results are scoped to the caller’s company via [Identity].fn_user_company_scope and exclude soft-deleted rows. All datasets are batched into a single round-trip.
  • MeilisearchSearchProvider — used when Meilisearch is configured; the SQL provider is the fallback otherwise.

When Meilisearch is enabled, declaring <Search> gives the entity a NOT NULL IsDirty flag plus an insert/update trigger (SearchDirtySqlHandlerService) that sets it. The MeilisearchSyncHostedService background worker periodically pushes dirty rows into the entity’s index (each document carries CompanyId for tenant isolation) and clears the flag. The audit change-logging trigger explicitly ignores IsDirty, so the two triggers coexist without producing phantom changes.

Method Route Returns
GET /api/v1/genie/search?term=&limit=&perGroupLimit= SuccessDataResult<SearchResponse> — ranked groups
GET /api/v1/genie/search?term=acme&perGroupLimit=5

Each SearchResponse carries the Term, a Truncated flag, and Groups; each group has an ObjectType, its Items (with Url, RecordKey, Context, Score) and a TotalMatches count.