22. Search and a team dashboard
Search Shelf's assets with a weighted text index, best match first and in the user's language, and show how many pins each member added per month in the team's time zone with one declared aggregation: the missing-index warning and its fix.
Shelf’s gallery filters by tag. It can’t answer “where is that handbook about onboarding?”, and a team can’t see who is pinning what. Two features of the query language fix that, both declared in config so their cost is designed:
- a text index and
$search, for words in prose, ranked by relevance; - an aggregation, for counts and summaries that a dashboard shows without a function per number.
A text index on assets
Add an entry to config/shelf/main/assets/indexes.json. Its fields are weighted: a word in the title counts ten times a word in the body.
{ "text": { "fields": { "title": 10, "tags": 5, "body": 1 }, "default_language": "english" } }
examples/config/shelf/main/assets/indexes.json
[
{"fields": ["-published_at"]},
{"fields": ["tags", "-published_at"]},
{"fields": ["_meta.owner", "-published_at"]},
{"text": {"fields": {"title": 10, "tags": 5, "body": 1}, "default_language": "english"}}
]backd provision (or the start of backd itself) creates it. Declaring it later is the same as changing it: the index is rebuilt, the startup log says so, and a search made meanwhile answers 503 index_building for a few seconds.
Search, best match first
const page = await assets.search('onboarding "style guide" -draft', { where: { published_at: { $ne: null } }, limit: 9 })
page.items[0]._meta.score // the relevance
- Words match any of them, a quoted phrase must appear as written, and
-draftleaves out what has it. - Results come best first (
order_by=_relevance), ties byid. Pages go byskip, up to the first 1,000 results. - The query is stemmed in the user’s language: their locale, else the browser’s
Accept-Language, else the index’sdefault_language. A Spanish reader’scorriendofindscorrer. To search in another language, passlanguage: 'es'. - The read rules apply first, so a search shows exactly what a list would.
The gallery’s search box is that call, and clearing it brings the list back:
const page = this.q.trim()
? await assets.search(this.q.trim(), { ...common, where: this.where(), skip: this.assets.length })
: await assets.list({ ...common, where: this.where(), orderBy: this.sort, after: this.after })
$search is for prose. $contains (the tag filter) stays right for exact fragments of short fields.
Declare what a dashboard may count
A team’s dashboard shows pins per member per month. A collection says which fields can be summarized; add this at the end of config/shelf/main/pins/collection.yaml:
aggregate:
group_by: [author, _meta.created_at]
Restart backd and read its log:
WARN shelf/main/pins: aggregate.group_by "author" has no index that starts with it, so grouping by it scans every matching document: add {"fields": ["author"]} to indexes.json
backd config check prints the same line. Nothing is wrong yet: pins are few. But an aggregation reads every pin the request selects, so a team with a hundred thousand of them would feel it. The warning is there so the index is a decision, not a surprise. Create config/shelf/main/pins/indexes.json:
[
{ "fields": ["author"] }
]
and the warning is gone. (_meta.created_at needed none: every collection already has an index that starts with it.)
A time zone for the team
A month starts at midnight somewhere. A pin made at 23:30 UTC on March 31st belongs to April for a team in Auckland. The team’s zone is a claim, next to its plan, that only an operator writes; team-plan now takes it too:
examples/config/shelf/main/_functions/team-plan/index.ts
// team-plan: set a team's plan and time zone, which the pin function (chapter 20) and the
// dashboard (chapter 22) read. Both are claims of the team's organization.
import { defineFunction } from "@backd/functions";
export default defineFunction("main/team-plan", async (ctx) => {
const { org, plan, timezone } = ctx.input;
if (plan === undefined && timezone === undefined) {
throw ctx.error(400, "nothing_to_set", "Give a plan, a timezone or both");
}
if (timezone !== undefined) {
try {
new Intl.DateTimeFormat("en", { timeZone: timezone });
} catch {
throw ctx.error(400, "invalid_timezone", "Not an IANA time zone, like Europe/Madrid");
}
}
try {
// setClaims replaces them all: start from what the team has.
const current = await ctx.admin.orgs.get(org);
const claims = { ...current.claims };
if (plan !== undefined) claims.plan = plan;
if (timezone !== undefined) claims.timezone = timezone;
await ctx.admin.orgs.setClaims(org, claims);
return { org, plan: claims.plan as string | undefined, timezone: claims.timezone as string | undefined };
} catch (err) {
// An unknown team is the caller's mistake, not a failure of ours.
if ((err as { status?: number }).status === 404) throw ctx.error(404, "not_found", "No such team");
throw err;
}
});setClaims replaces all the claims, so the function reads the team’s first and keeps the plan when only the zone changes. An unknown zone is 400 invalid_timezone.
The dashboard
const org = await backd.orgs.get(team)
const tz = org.claims.timezone ?? 'UTC'
const { groups } = await pins.inOrg(team).aggregate({
groupBy: ['author', '_meta.created_at:month'],
metrics: ['count'],
tz,
orderBy: ['-_meta.created_at:month', '-count'],
})
// groups[0] → { key: { author: 'u_9', '_meta.created_at:month': '2026-09' }, count: 12 }
_meta.created_at:monthis a bucket:day,week(ISO),monthoryear, cut intz. Keys are text (2026-09).- The read rule runs before grouping: a member counts only the pins of their team; someone in no team is refused (
403, as for a list) and a member of another team counts nothing of this one, andaggregateon a collection with noaggregate:(tryshares) is400 aggregate_not_enabled. - Months with no pins aren’t returned. For a chart,
fillBuckets(groups, { field: '_meta.created_at', unit: 'month', from: '2026-01', to: '2026-12' })adds them withcount: 0. - A dashboard loaded on every page is a better fit for a counter a function keeps up to date than for an aggregation per visit; see Aggregations.
You should see
- A search for a word in a title and in another asset’s body returns the title first, with
_meta.score;-wordand"a phrase"behave as written; a$searchinside$oris400. - Declaring
aggregate:without the index logs the warning; addingindexes.jsonsilences it. - After
team-plangives the team atimezone, the Teams view shows Pins per member per month in that zone, and an outsider’s aggregate is refused or empty.
Read Full-text search, Aggregations and the client.