Search

Query full-text search indexes and inspect their build status from the SDK.

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:

  • total and facets reflect 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 filter role in the index config. Filtering on a field without it fails the query with Field '<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 number and datetime fields. A range on a text field returns Range 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 filter applies 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 highlighting
  • filter narrows results by field value (scalar = equality, object = range); the field needs the filter role, and range operators need a number/datetime field
  • Results respect the caller's read permissions on the source collections (item_filter post-filter; total/facets are 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() and status(name) enumerate indexes and report build freshness