Standards Docs
Concepts

Search

Typed full-text search across records via a mandatory SearchAdapter (Meilisearch by default).

standards exposes typed search across records through a SearchAdapter composed onto the DatabaseAdapter. The adapter is mandatory — without one, bootstrap throws SearchAdapterRequiredError.

// @noverify
import { useSearchRecords } from "@stndrds/react";

function ContactSearch({ q }: { q: string }) {
  const { data, isLoading } = useSearchRecords("contact", q);

  if (isLoading) return null;
  return (
    <ul>
      {data?.data.map((r) => (
        <li key={r.id}>{String(r.values.firstName)}</li>
      ))}
    </ul>
  );
}

useSearchRecords(objectName, query, options?) accepts an optional options object with limit, offset, sorts, filters, minQueryLength (default 1), and a select transformer.

How it works

When MeilisearchSearchAdapter is configured on the backend, records sync to Meili on create, update, and delete. Search queries hit Meili and return results ranked by relevance. The adapter is required at construction time — the runtime no longer ships a Postgres ILIKE fallback path, and bootstrap will throw SearchAdapterRequiredError if no SearchAdapter is composed.

For paginated search with infinite scroll, use useInfiniteSearchRecords(objectName, query, options?). It accepts the same options plus pageSize (default 20) and exposes fetchNextPage / hasNextPage from TanStack Query.

Filter rules

FilterRule is the typed query shape used to constrain records:

// @noverify
import type { FilterRule } from "@stndrds/schema";

// Equality
const byStage: FilterRule = { attribute: "stage", operator: "eq", value: "qualified" };

// Range
const recentDeals: FilterRule = { attribute: "createdAt", operator: "gte", value: "2025-01-01" };

// List membership
const multiStatus: FilterRule = { attribute: "status", operator: "in", value: ["open", "pending"] };

Pass a FilterState (combinator + rules array) to useSearchRecords via the filters option. For adapter-level selection and how the backend chooses between Meili and Postgres, see adapters.

Dynamic filter values

A filter value can be a query-time token instead of a literal. Store the token in the rule and the runtime resolves it from the execution context before the query reaches any adapter — so a single saved view shows "my records" or "due today" for whoever opens it.

// @noverify
import { currentActor, today, now } from "@stndrds/schema";
import type { FilterRule } from "@stndrds/schema";

// @me — resolves to the user behind the current actor (use on `user` attributes)
const assignedToMe: FilterRule = { attribute: "assignee", operator: "is", value: currentActor() };

// @today — resolves to today's date in the context timezone (use on `date` attributes)
const dueToday: FilterRule = { attribute: "dueDate", operator: "is", value: today() };

// @now — resolves to the current timestamp
const overdue: FilterRule = { attribute: "dueDate", operator: "before", value: now() };

Each helper returns a DynamicValue discriminated by its dynamic key; use isDynamicValue(value) to detect one. @me resolves to the user id behind the current actor — user-type attributes store user ids, not actor ids — so it only matches for user actors. Agents, API keys, and system actors have no user behind them, so @me is unresolvable for them and its rule is dropped rather than matching nothing (the same fallback applies when there is no actor at all).

Edge-property filters

You can filter records by a property on a qualified edge — for example, "contacts whose relationship to this company has role = "primary"". Set property on the FilterRule alongside the reference attribute:

// @noverify
import type { FilterRule } from "@stndrds/schema";

const primaryContacts: FilterRule = {
  attribute: "company",  // relation attribute with .qualifyWith()
  property: "role",
  operator: "eq",
  value: "primary",
};

Edge-property filters are resolved inside the search adapter. The Meilisearch adapter projects edge properties into the indexed document (under edge_properties.*) at write time, so rule.property predicates evaluate directly against the index — no separate ID lookup. See relations for how qualified edges are declared with .qualifyWith().

Existence quantifier

Edge-property rules accept an optional quantifier to control existence semantics: "any" keeps records with at least one matching edge, "none" keeps records with no matching edge (SQL EXISTS / NOT EXISTS). When omitted, the polarity is derived from the operator.

// @noverify
import type { FilterRule } from "@stndrds/schema";

// Contacts with NO contract signed in the last 5 years
const dormant: FilterRule = {
  attribute: "contracts",
  property: "signedAt",
  operator: "is_within",
  value: { amount: 5, unit: "years", direction: "past" },
  quantifier: "none",
};

The is_within operator takes a RelativeDateValue (amount + unit + direction) and resolves to a date range relative to the query time. A malformed value (missing unit, non-positive amount, or any partial shape) is rejected with HTTP 400 on both the /list and /search paths — it no longer silently widens the filter to the entire dataset.

Breaking in vNEXT: DatabaseAdapter.search is now mandatory. The legacy ILIKE fallback has been removed, and SearchUnavailableError no longer exists — bootstrap throws SearchAdapterRequiredError instead. Compose MeilisearchSearchAdapter (or another SearchAdapter) at adapter construction time.

On this page