Search
Query full-text search indexes and inspect their build status from the SDK.
#Search
sdk.search works with the project's full-text search indexes — a SQLite FTS
index built directly from one or more collections, served from the edge.
Since SDK 0.2.0 it covers the full lifecycle: querying and status for any
caller, plus create/update/rebuild/delete for admin or API-key callers with
the right permissions.
#Query an index
const { results, total, facets } = await sdk.search.query('articles', {
q: 'climate policy',
facets: ['category'],
page: 1,
limit: 20,
highlight: true
});
Results are RBAC post-filtered against the source collections — every hit
on the returned page is re-authorized against the source collection's
row-level item_filter for the calling user, so a hit (and its snippet) is
never returned for a record the caller cannot read. Public indexes are
queryable without signing in; anonymous callers are authorized as the public
role, whose item_filter still applies.
Two honesty notes:
totalandfacetsreflect the pre-authorization matched set — only the returned page is filtered. Don't present the total as authorization-accurate when source collections have row scoping.- Row scoping must live in the collection's policy (
item_filter) to be enforced here. App-side visibility rules that aren't encoded in collection policy are not replicated by the index.
Options:
| Option | Meaning |
|---|---|
q |
FTS query string |
filter |
restrict results by field value (see below) |
geo |
restrict to a radius or bounding box on a geo-role field |
sort |
sort spec, e.g. -created_at |
facets |
field name(s) to compute facet counts for |
page, limit |
pagination |
semantic |
enable vector ranking (when the index supports it) |
highlight |
return highlighted snippets |
#Filtering results
Narrow a query to a subset of the index with filter. Each key is a field name;
a scalar is an equality match and an object is a range:
const { results } = await sdk.search.query('posts', {
q: 'release notes',
filter: {
forum_id: 'general', // equality
created_at: { gte: cutoff } // range
}
});
Range bounds are gt, gte, lt, lte, and they combine to express a window
({ gte: start, lt: end }). null/undefined values are skipped, so an
optional filter can be passed straight through without building the object
conditionally.
Two rules the server enforces:
- The field must carry the
filterrole in the index config. Filtering on a field without it fails the query withField '<name>' is not filterable— the error response also lists the index's available fields, so a 400 here tells you exactly what you can filter on. - Range operators are only valid on
numberanddatetimefields. A range on a text field returnsRange operator '<op>' is not supported for field '<name>'.
Filters are AND-ed together, and with q. Values are coerced to the field's
declared type and bound as SQL parameters.
Filtering a search index is not the same as filtering collection records. This
filterapplies to a compiled search index and uses the index's own field roles. The collection records API has a separate, richer filter grammar (_eq,_in,_and, …) that does not apply here.
#Geo queries
When a field has the geo role, constrain results to a radius or a bounding box
(one geo constraint per query):
// Within 25km of a point
await sdk.search.query('places', {
geo: { field: 'location', nearby: { lat: -33.87, lng: 151.21, radiusKm: 25 } },
sort: 'distance' // only valid with an active geo query
});
// Inside a bounding box: [west, south, east, north]
await sdk.search.query('places', {
geo: { field: 'location', bbox: [150.5, -34.1, 151.5, -33.5] }
});
A geo-role field must come from a collection field holding a single
point — a point field, or a plain column holding "lat,lng" / {lat,lng}.
The compiled index stores one coordinate pair per record and no geometry, so
pointing the geo role at a field that can hold extent (polygon,
linestring, multipoint, geometry, …) fails the rebuild with an error
naming the field. Measuring to such a record's centre would be wrong by up to
the size of the shape; the
collection records API measures to the geometry
itself (_geo_near, _geo_within, _geo_bbox) and is the surface to use for
areas.
A radius larger than the field's configured maxRadiusKm fails the query
with the same 400 every other validation error returns —
Radius 200km exceeds maximum 100km for field 'location', alongside the index's
available fields. Nothing comes back, so narrow the radius yourself rather than
relying on the server to do it.
#Over HTTP
The SDK serializes the above to query parameters, so the same filters work against the REST endpoint directly:
GET /api/v1/search-indexes/posts/query?q=release+notes
&filter.forum_id=general
&filter.created_at_gte=1750000000
&geo.location=nearby:-33.87,151.21,25
Equality is filter.<field>=, ranges are filter.<field>_gte= (and _lte,
_gt, _lt), and geo is geo.<field>=nearby:lat,lng,radiusKm or
geo.<field>=bbox:W,S,E,N.
#Semantic queries
semantic: true requires the gateway's Workers AI (AI) binding. On an
instance without it, the query fails with a typed 503 whose body is
{ "error": "semantic_unavailable", ... } — catch it and retry without
semantic, or probe availability up front via GET /health
(bindings.AI, and 'AI' listed in missing.optional when absent). A
transient AI failure on an instance that has the binding does not error:
the query silently returns the full-text ranking. See
Enabling semantic search for the full
setup guide.
#Create and manage indexes (admin)
// Define an index straight over collections — no shadow "search docs"
// collection or manual reindex pipeline needed.
await sdk.search.create({
indexName: 'forum_search',
sources: [
{
collection: 'posts',
url: '/posts/{id}', // required: link template for a hit's source record
// Optional build-time row filter: filter: { status: 'published' }
fields: [
// `roles` is an array; at least one field must be `search` or `filter`.
{ name: 'body', roles: ['search'], weight: 2 },
{ name: 'title', roles: ['search', 'display'] },
{ name: 'topic_id', roles: ['filter'] },
// Pull a value from a related collection onto each hit:
{ name: 'topic_title', roles: ['display'], expand: 'topics.title' }
]
}
],
semanticSearch: true // vector re-ranking; needs the worker's AI binding
});
// The index starts empty — compile it, then poll status() until 'complete'.
await sdk.search.rebuild('forum_search');
Field roles: search (full-text matched), filter (usable in filter at query time), sort, display, facet via meta, vector (semantic), geo (radius/bbox queries). Each field needs at least one; the index as a whole needs at least one search or filter field.
update(config) replaces the config (rebuild afterwards to apply),
delete(name) removes the index and its compiled artifacts, and get(name)
returns the full config + build status.
Writes to source collections do not auto-rebuild the index — heavy changes
(bulk imports, truncate, collection delete) mark it stale, and queries keep
serving the old compiled data (flagged via meta.stale / meta.staleReason
in the query response) until you rebuild. Schedule rebuilds with a flow using
the build_search_index operation, or from Settings → Databases in the
dashboard.
#List indexes & check freshness
const { items } = await sdk.search.list();
const status = await sdk.search.status('articles');
// status.status: 'complete' | 'never_built' | 'building' | …
// status.built_at, status.record_count, status.version
#What you learned
sdk.search.query(name, opts)runs full-text/semantic queries with facets and highlightingfilternarrows results by field value (scalar = equality, object = range); the field needs thefilterrole, and range operators need anumber/datetimefield- Results respect the caller's read permissions on the source collections (
item_filterpost-filter;total/facetsare pre-authorization) create()/update()/rebuild()/delete()manage the index lifecycle from the SDK (0.2.0+)- Indexes build directly over collections and are marked stale — never auto-rebuilt — when source data changes heavily
list()andstatus(name)enumerate indexes and report build freshness