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.
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.
better-supabase sql add searchimport { 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
when that module is installed, and keeps embeddings when
vector search is installed and dimensions is
set.
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.
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 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.
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
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.
await search.touch("note", noteId);
await search.star("note", noteId);
const recent = await search.recents({ limit: 5 }).orThrow();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.
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
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:
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
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.
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
The hook has no markup, so it drives any command component. With the ReUI
(shadcn) Command dialog:
"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
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.
Last updated on
Undo
Apply the undo tokens that block methods return, on the server with createUndo and in the browser with a useUndoStack for a toast's Undo button.
Metrics
Declare metrics with defineMetric, roll them up into metric_points or read small tables directly, and read series and KPIs with a previous-range comparison.