# Search

URL: https://developers.uidu.org/docs/domains/search

> Run filtered, paginated search across every searchable record type in your workspace.

* **Use it for:** [Public website](https://developers.uidu.org/docs/paths/public-website.md) · [Server integration](https://developers.uidu.org/docs/paths/server-integration.md)
* **Reads:** public token — works in the browser
* **Writes:** none — read-only today
* **Auth:** what each token can do and where it may run — [Authentication](https://developers.uidu.org/docs/foundations/authentication.md)

`search` queries the workspace's search index: one call, filtered and sorted, across the record
types you name. The index covers about forty of them — `Contact`, `Deal`, `Donation`,
`DonationCampaign`, `Event`, `Form`, `Product`, `Story`, `Task`, `Note`, `Employee`, `ModelItem` and
more (the `node` union of `SearchResult` lists them all).

## Mental model

```
search(client, { models, params, first, after })
└── SearchResult                 a Relay connection, not a flat array
    ├── totalCount, totalPages   how many matches
    ├── term                     the term the engine searched for
    ├── took                     query time, in ms
    ├── pageInfo                 { hasNextPage, endCursor }
    └── edges[]                  { cursor, node: { __typename, id } }
```

* **Hits are references.** Each `node` carries only `__typename` and `id`. Narrow on `__typename`,
  then fetch what you want to show with that domain's `get*` (`getEvent`, `getStory`, `getForm`…).
  The SDK's query is fixed, so you can't ask `search` for more fields per type.
* **`models`** restricts the search to record types: `['Event', 'Story']`. Always pass it. With a
  single model the engine applies that model's own defaults (scoping, newest first); a single model
  that isn't searchable fails the request.
* **Not indexed:** knowledge-base articles. To search a help center, filter its articles in memory
  ([Help Center](https://developers.uidu.org/docs/domains/help-center.md)).
* **Scope.** Results are limited to the client's workspace and leave out trashed (discarded)
  records. Your filters can narrow that scope, never widen it.
* **If the index fails** (missing, being rebuilt), you get an empty result rather than an error — so
  an empty result isn't proof that nothing matches.

**`params`** — every key is optional:

```ts
{
  term?: string;                       // full-text term; omitted = everything ('*')
  filters?: Array<{
    id: string;                        // the field, camelCase: 'createdAt', 'name'
    operator: string;                  // see below
    value?: unknown;                   // JSON
    kind?: string;                     // field kind: 'date', 'number', 'multipleSelect'…
  }>;
  sorting?: Array<{ id: string; desc?: boolean }>;
  grouping?: string[];
  options?: { /* engine options: fields, highlight, aggs, boostBy, misspellings… (JSON) */ };
}
```

| `operator`                       | Matches                                                                                      |
| -------------------------------- | -------------------------------------------------------------------------------------------- |
| `=` / `eq`                       | exactly `value` — case-sensitive; with `kind: 'multipleSelect'`, all of `value`              |
| `in`                             | any of `value` (an array)                                                                    |
| `!=` / `notIn`, `doesNotContain` | anything but `value`                                                                         |
| `contains`                       | `value` as a full-text term on that field — case-insensitive, fuzzy                          |
| `>`, `>=`, `<`, `<=`             | ranges on numbers and dates                                                                  |
| `between`, `notBetween`          | `value: [from, to]`; leave either end empty for an open range                                |
| `dateOverlap`                    | a date range that overlaps `value`                                                           |
| `null`, `notNull`                | empty or set; `kind: 'number'` or `'date'` treats only `null` as empty, other kinds `''` too |

Sorting: relevance comes first, then your `sorting` in order, then the default order (newest
first). Pagination is cursor-only: pass the last `pageInfo.endCursor` as `after`; there is
no going back. `hasNextPage` is `true` whenever a page comes back full, so when the total is an exact
multiple of `first` the last request returns an empty page.

## Use cases

### A search box over events and stories

Goal: `/search?q=…` shows matching events and stories with their titles, from one route.

```tsx
// src/app/search/page.tsx
import Link from 'next/link';
import { getEvent, getStory, search } from '@uidu/client';
import { uidu } from '@/lib/uidu';

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q = '' } = await searchParams;
  if (q.trim().length < 2) return <p>Type at least two characters.</p>;

  const result = await search(uidu, {
    models: ['Event', 'Story'],
    params: { term: q },
    first: 10,
  });
  const hits = (result?.edges ?? []).flatMap((e) => (e?.node ? [e.node] : []));

  // Hits are { __typename, id }: fetch each one's title from its own domain.
  const rows = await Promise.all(
    hits.map(async (hit) => {
      switch (hit.__typename) {
        case 'Event': {
          const event = await getEvent(uidu, { id: hit.id });
          const href = `/event/${encodeURIComponent(hit.id)}`;
          return event && { id: hit.id, kind: 'Event', title: event.name, href };
        }
        case 'Story': {
          const story = await getStory(uidu, { id: hit.id });
          const href = `/stories/${encodeURIComponent(hit.id)}`;
          return story && { id: hit.id, kind: 'Story', title: story.name, href };
        }
        default:
          return null;
      }
    }),
  );

  return (
    <>
      <p>
        {result?.totalCount ?? 0} results in {result?.took ?? 0} ms
      </p>
      <ul>
        {rows.map(
          (r) =>
            r && (
              <li key={r.id}>
                <Link href={r.href}>{r.title}</Link> <small>· {r.kind}</small>
              </li>
            ),
        )}
      </ul>
    </>
  );
}
```

* Reads work with the public token, so the same call can run in the browser. Doing it in a Server
  Component keeps the follow-up `get*` calls in one place and off the visitor's connection.
* Keep `first` small: every hit costs one more request to render.

### A filtered, sorted listing with "load more"

Goal: the stories created since the start of the year, newest first, twenty at a time.

```ts
// src/app/stories/archive/actions.ts
'use server';
import { search } from '@uidu/client';
import { uidu } from '@/lib/uidu';

export async function storiesSince(from: string, after?: string) {
  const result = await search(uidu, {
    models: ['Story'],
    first: 20,
    after,
    params: {
      filters: [{ id: 'createdAt', kind: 'date', operator: '>=', value: from }],
      sorting: [{ id: 'createdAt', desc: true }],
    },
  });

  return {
    ids: (result?.edges ?? []).flatMap((e) => (e?.node ? [e.node.id] : [])),
    total: result?.totalCount ?? 0,
    // Pass back as `after` for the next twenty; null when there are no more.
    next: result?.pageInfo.hasNextPage ? (result.pageInfo.endCursor ?? null) : null,
  };
}

// storiesSince('2026-01-01')            → first page
// storiesSince('2026-01-01', page.next) → the next one
```

* Filter `id`s are the indexed field names in camelCase (`createdAt`, not `created_at`). A filter
  on a field the model doesn't index matches nothing.
* `=` is exact and case-sensitive (`Vanini` ≠ `vanini`). For "name contains", use `contains`, or
  put the text in `term`.
* With no `term`, the search matches everything that passes the filters — this is a plain listing,
  ordered by your `sorting`.

## API at a glance

Every `@uidu/client` function in **Search**, generated from the source — the [full reference](https://developers.uidu.org/docs/reference/client.md#search) has the rest of the SDK.

| Function                                                                               | Kind | Auth         | CLI | Description                                                                                                                  |
| -------------------------------------------------------------------------------------- | ---- | ------------ | --- | ---------------------------------------------------------------------------------------------------------------------------- |
| [`search`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/search.ts) | read | public token | —   | Search the workspace index, optionally limited to `models`, with filters and sorting in `params`; page with `first`/`after`. |

**Types:** `SearchResult`
