# Search

> A search index kept up by triggers from config.subjects, search(q) as the caller, recents and favorites, and useCommandSearch for a Cmd+K dialog.

Source: https://bettersupabase.com/docs/blocks/search

`search` keeps one `search_documents` row per searchable subject. Triggers
generated from each `search` entry in `config.subjects` write the row when the
source row changes, and `search(q)` reads the index as the caller: full-text
matches, prefixes while the user types and trigram matches for typos, filtered
to what the caller can read. With `vector-search` and embeddings, the results
fuse full-text and nearest-neighbour ranks.

```bash
better-supabase sql add search
```

```ts
import { createSearch, searchSink } from "better-supabase/blocks/search";
import { useCommandSearch } from "better-supabase/blocks/search/react";
```

The module needs `tenant`, `access` and `subjects`. It reads recents and
favorites from the per-user state of [Share links](/docs/blocks/share-links)
when that module is installed, and keeps embeddings when
[vector search](/docs/blocks/vector-search) is installed and `dimensions` is
set.

## Declare what to index [#declare-what-to-index]

Give each subject a `search` entry. `title` defaults to the subject's
`title` column, and `body` lists the columns joined into the searchable body.
`url` and `deletedAt` come from the same registry entry.

```ts title="better-supabase.config.ts"
subjects: {
  note: {
    table: "public.notes",
    title: "title",
    url: "/notes/{id}",
    deletedAt: "deleted_at",
    permissions: { read: "notes.read" },
    search: { body: ["body", "summary"] },
  },
  customer: { table: "public.customers", title: "name", search: {} },
},
sql: {
  modules: {
    search: { options: { config: "english" } },
  },
},
```

| Option       | Default  | What it sets                                                                   |
| ------------ | -------- | ------------------------------------------------------------------------------ |
| `config`     | `simple` | The text search configuration for the `tsvector` and the query                 |
| `dimensions` | none     | The embedding size; with `vector-search` the index keeps an `embedding` column |

Each searched subject gets an `after insert or delete or update` trigger on
the columns the entry reads, so editing a column the index ignores writes
nothing. Subjects with `tenant: false` are skipped, because every document
belongs to a tenant. Installing the module indexes the rows that already
exist, and `search.reindex()` does it again after a config change.

## Search [#search]

`search` runs as the caller. The table's policy keeps the caller's tenants
(`search.read`), and `can_subject` drops each hit the caller can't `read`, so
a document is visible only where its source row is.

```ts
const search = createSearch({ transport, service });

const hits = await search
  .search("revnue plan", { types: ["note"], limit: 10 })
  .orThrow();
// [{ subjectType: "note", subjectId, title, url, snippet, score }]
```

The query is lowercased and stripped of accents (`unaccent`), then matched
three ways: `websearch_to_tsquery` (quoted phrases, `or`, `-word`), each word
as a prefix, and trigram similarity on the title for typos. A trashed row
(its `deletedAt` column set) has no document, and a restored row gets it
back.

| Hint                     | When                                          |
| ------------------------ | --------------------------------------------- |
| `SEARCH_QUERY_TOO_LONG`  | The query is longer than 256 characters       |
| `SEARCH_SIGNED_OUT`      | `touch` or `star` without a signed-in user    |
| `SEARCH_SUBJECT_UNKNOWN` | `refresh` for a type without a `search` entry |

## Recents and favorites [#recents-and-favorites]

`touch` records that the caller opened a subject and `star` stars it, both in
the share-links `subject_user_state` table. `recents()` and `favorites()`
list them newest first, with the title and URL from the index, and only for
subjects the caller can still read.

```ts
await search.touch("note", noteId);
await search.star("note", noteId);
const recent = await search.recents({ limit: 5 }).orThrow();
```

## Trash [#trash]

Trashed rows drop out in one of two ways. A subject with a `deletedAt`
column needs nothing more: the trigger removes the document when the column
is set. A trash table that moves rows elsewhere emits events instead, and
`searchSink` turns them into index calls. Build `search` with a service
transport for it.

```ts
outbox.relay("search", searchSink(search));
```

| Event              | Action    |
| ------------------ | --------- |
| `subject.trashed`  | `remove`  |
| `subject.purged`   | `remove`  |
| `subject.restored` | `refresh` |

Pass `on` to map other event types. A subject type without a `search` entry
is skipped.

## Embeddings [#embeddings]

With `dimensions` set and `vector-search` installed, the index keeps an
embedding per document and clears it when the text changes. A job embeds what
is pending, through any function that returns one vector per text, such as the
AI SDK's `embedMany`:

```ts
await search.embedPending(async (texts) => {
  const { embeddings } = await embedMany({ model, values: [...texts] });
  return embeddings;
});

const hits = await search
  .search(q, { embedding: await embedQuery(q) })
  .orThrow();
```

`search` then fuses the full-text and nearest-neighbour ranks by reciprocal
rank. `setEmbedding` skips a vector whose text changed since it was read.

## Cmd+K [#cmdk]

`useCommandSearch` holds the state of a command dialog: Cmd+K and Ctrl+K
toggle it, the query is debounced, stale responses are dropped, and an empty
query shows favorites and recents. `select` records the item as opened and
closes the dialog.

```tsx
const search = useCommandSearch({ organizationId });
```

| Field                    | What it holds                                              |
| ------------------------ | ---------------------------------------------------------- |
| `open`, `setOpen`        | The dialog state                                           |
| `query`, `setQuery`      | The input                                                  |
| `groups`                 | Favorites and recents, or the hits grouped by subject type |
| `loading`, `error`       | A query is running, and the last error                     |
| `select(item)`, `star()` | Records an open and closes; stars or unstars an item       |

## With ReUI [#with-reui]

The hook has no markup, so it drives any command component. With the ReUI
(shadcn) `Command` dialog:

```tsx title="components/command-search.tsx"
"use client";

import { useRouter } from "next/navigation";
import { useCommandSearch } from "better-supabase/blocks/search/react";
import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";

const LABELS: Record<string, string> = {
  favorites: "Favorites",
  recents: "Recent",
  note: "Notes",
};

export function CommandSearch({ organizationId }: { organizationId: string }) {
  const router = useRouter();
  const search = useCommandSearch({ organizationId });
  return (
    <CommandDialog open={search.open} onOpenChange={search.setOpen}>
      <CommandInput
        value={search.query}
        onValueChange={search.setQuery}
        placeholder="Search"
      />
      <CommandList>
        <CommandEmpty>
          {search.loading ? "Searching" : "No results"}
        </CommandEmpty>
        {search.groups.map((group) => (
          <CommandGroup
            key={group.key}
            heading={LABELS[group.key] ?? group.key}
          >
            {group.items.map((item) => (
              <CommandItem
                key={`${item.subjectType}:${item.subjectId}`}
                value={`${item.subjectType}:${item.subjectId}`}
                onSelect={() => {
                  void search.select(item);
                  if (item.url) router.push(item.url);
                }}
              >
                {item.title ?? item.subjectId}
              </CommandItem>
            ))}
          </CommandGroup>
        ))}
      </CommandList>
    </CommandDialog>
  );
}
```

`cmdk` filters items itself; set `shouldFilter={false}` on the `Command`
when the dialog lets you pass it, since the server already ranked them.

## Hooks [#hooks]

`before_search_document_index` and `after_search_document_index` run around
each document write with the subject type and id. `createSearch` takes
`hooks` around its methods, as every block does.