SearchAsh.GlobalIndex (search_ash v0.4.2)

Copy Markdown View Source

Ash extension that turns a resource into a unified, cross-entity search index — one row per indexed source object, searched with a single ranked query (Option B).

defmodule MyApp.Search.Document do
  use Ash.Resource,
    domain: MyApp.Search,
    data_layer: AshPostgres.DataLayer,
    extensions: [SearchAsh.GlobalIndex]

  postgres do
    table "search_documents"
    repo MyApp.Repo
  end

  # Tenant-scope the index like any resource (optional):
  multitenancy do
    strategy :attribute
    attribute :org_id
  end

  global_index do
    default_language :fr
  end

  attributes do
    uuid_primary_key :id
    attribute :org_id, :string, allow_nil?: false, public?: true
  end
end

It generates the index columns (source_type, source_id, language, search_text, archived, label, label_normalized, excerpt), a unique_source identity, a GIN index, an :upsert action, the :search_rank and :label_match_tier calculations and a :global_search read action that filters and ranks: label exact > starts-with > contains > body match, then ts_rank (prefix-aware), then primary key. It hides archived rows by default (include_archived?: true shows both), restricts to the types you pass (types: [:facture]nil/[] mean no filter), and paginates (page: [limit: 20, offset: 0, count: true]; offset and keyset, never required). A non-blank query whose tokens are all eliminated ("de") returns nothing; only a blank/absent query lists everything. Per-type totals for tab badges come from SearchAsh.counts_by_type/3.

Typo tolerance (fuzzy?)

fuzzy? true additionally matches the normalized label by trigram similarity and substring — duont finds Dupont, 0012 finds BL-2024-0012 — served by one trigram GIN index, with fuzzy-only matches ranking behind full-text ones. It is opt-in because it needs the pg_trgm extension: add "pg_trgm" to your repo's installed_extensions before generating migrations. Without the option nothing requires the extension.

fuzzy_threshold (default 0.35) is how similar a label has to be. The default comes from measuring the two cases that pull in opposite directions:

pairsimilarity
duont / dupont0.44a real typo — keep it
maraichere / maraicher0.75keep it
bl-2024-0012 / fa-2024-01130.30look-alike reference — drop it
dupont / dupond0.56a genuine near-neighbour, kept

Anything from about 0.32 to 0.44 separates them; 0.35 sits in that band. Raise it for stricter matching. Lowering it below your database's pg_trgm.similarity_threshold (0.3 by default) does nothing: the trigram operator that the index can answer filters against that setting first, and this option only tightens what survives. To match more loosely than the database allows, raise the database setting.

The substring half is skipped for terms shorter than three characters. A trigram is three characters, so pg_trgm cannot serve a shorter pattern from its index: '%vi%' matched 66% of a 20k-row table in a sequential scan, while the similarity half — bounded by fuzzy_threshold — matched none of it. Short terms are still matched as a prefix by the full-text half, so vi finds Vidange but no longer every label that merely contains those letters. A two-character reference fragment is the one thing this gives up; 001 still reaches BL-2024-0012.

Source resources feed it with the SearchAsh.Source extension. Existing data is backfilled with SearchAsh.reindex/2, and a single row is reconciled after a write that bypassed Ash with SearchAsh.reindex_one/3.

Authorization

This index does not inherit the policies of the resources feeding it. It does honour its own: :global_search is a plain Ash read action, so policies you put on this resource compose with it. What they can authorize on is whatever an index row holds — source_type, archived, label, language, and your tenant attribute.

So a role that gates which kinds of thing a user may see works today:

policies do
  policy action_type(:read) do
    authorize_if expr(source_type in ^actor(:visible_types))
  end
end

source_type is stored as a string, so the actor's list must hold strings. Ash policies need a SAT solver (:picosat_elixir or :simple_sat).

Row-level ownership does not. No owner_id, team, or per-record visibility flag can reach an index row — SearchAsh.Source writes a fixed set of columns — so results would carry the label of rows a user cannot open. Note that label_field is yours: point it at a reference rather than at something sensitive and a result reveals that a match exists, not what it says. The same goes double for excerpt_length: an excerpt exposes content to whoever can search the index, so don't enable it on a sensitive resource — or make sure this index's policies account for it. Note too that this index answers "what may this user find", not "what may they do" — routing to the object applies the source's policies, so do not mirror write permissions here.

A result carries (source_type, source_id), so you can re-check rights when rendering. That is sound as a safety net over a policy that already filters in SQL — it drops almost nothing and ranking is untouched. It is not sound as the primary filter: Postgres ranked and paginated over rows you then discard, so page 1 can come back empty while the matches sit on page 5. Either way, count in the view — Ash.count on the action counts what SQL matched, before any render-time filtering.

For real row-level read filtering, use per-resource SearchAsh (search do … end): it queries the source table, so your policies apply, at the cost of cross-entity search. Copying ACLs into the index is a trap — authorization facts change independently of content, so nothing would trigger a re-index, and a stale index row is a security incident rather than a cosmetic one.

Summary

Functions

global_index(body)

(macro)