Search
Every generated resource exposes one search route instead of a detail route: there is no GET /blog-posts/:id — asking for one record is a filter on id that happens to return a single row, not a separate contract to learn.
POST /blog-posts/search
Authorization: Bearer <token>
Content-Type: application/json
{
"filters": [
{
"field": "title",
"operator": "like",
"value": "hello"
}
]
}Body fields
Section titled “Body fields”| Field | Type | What it does |
|---|---|---|
filters |
array | Each entry: field (must be in the blueprint’s filters list, plus id), operator (default =), value, type (and/or, default and), nested (a sub-group evaluated as one condition) |
sorts |
array | Each entry: field (must be in the blueprint’s sorts list), direction (asc/desc, default asc) — applied in the order given |
selects |
array | Each entry: field (must be an exposed field) — trims the response down to just these |
page |
number | Defaults to 1 |
cursor |
string | The meta.next_cursor of the previous response. Offered alongside page, not instead of it: cannot be combined with page, must be sent with the same sorts as the request that produced it, and is refused (query.invalid) when malformed or tampered with |
limit |
number | Must be one of the blueprint’s pagination.limits, and is refused outright on a resource that declares no pagination |
search.q / search.engine |
string | Free-text search — see Full-text search |
withTrashed / onlyTrashed |
boolean | Gated behind the delete permission, not the read one |
capabilities |
array | Which per-record capability decisions to attach — see below |
includes |
array | Each entry: relation (must be in the blueprint’s includes), alias, filters/sorts/selects scoped to the related resource, limit — see below |
aggregates |
array | Each entry: relation (must be in the blueprint’s aggregates), type (count/exists/avg/sum/min/max), field (required for avg/sum/min/max), alias, filters scoped to the related resource — see below |
Includes and aggregates
Section titled “Includes and aggregates”A blueprint declares an include or aggregate by pointing at another resource rather than retyping its shape:
includes: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostIdaggregates: - relation: notes resource: BlogPostNote type: hasMany foreignKey: blogPostIdfilters/sorts/selects (or filters/fields for an aggregate) are derived once, at generation time, from the referenced resource’s own blueprint — nothing is hand-typed twice. The referenced resource must be declared routed: false: it exists only to describe its shape to whoever includes/aggregates it, and gets no controller, no service, no capabilities of its own.
Those derived selects are also what an include returns when the request names no fields of its own. An included relation has no view function of its own — its rows are merged onto the record as the database returned them — so without a select the response would carry every column the related table has, hidden blueprint fields included. The declared list already excludes those, so it is the default rather than something a caller has to remember to ask for.
type is one of:
hasMany— a real Prisma relation. NeedsforeignKey.morphMany— a polymorphic relation, which Prisma has no native concept of. NeedsforeignKey,discriminatoranddiscriminatorValue(the column and value that mark “this row belongs to a BlogPost” on the other side).
A hasMany count/exists aggregate resolves through Prisma’s own filtered _count, in the same query as the page itself — the cheapest path available. Everything else — avg/sum/min/max on any relation, or any aggregate at all on a morphMany relation, which Prisma cannot express through include — resolves as one extra batched query per aggregate (a groupBy or findMany keyed by the foreign key), never one query per row:
POST /blog-posts/search{ "includes": [{ "relation": "notes" }, { "relation": "comments" }], "aggregates": [ { "relation": "notes", "type": "count" }, { "relation": "notes", "type": "avg", "field": "rating" }, { "relation": "comments", "type": "exists" } ]}{ "data": [ { "id": "cly8x7g9k0000abc123def456", "notes": [{ "id": "n1", "body": "first note", "rating": 4 }], "comments": [{ "id": "c1", "body": "nice one" }], "notes_count": 2, "notes_avg": 3, "comments_exists": true } ]}An include’s default result key is its relation name; an aggregate’s is ${relation}_${type} — both overridable with alias. A morphMany include has no limit: it cannot be windowed per parent the way a native relation can, since there is no Prisma relation to attach a nested include to.
A hasMany include may itself carry a further includes — a relation of a relation, up to two levels total:
{ "includes": [{ "relation": "notes", "includes": [{ "relation": "replies" }] }] }Only when the referenced blueprint declares one; see Nested includes. A nested include has no alias of its own — its rows come back nested under the relation’s real name, and morphMany never nests.
A hasMany include declaring ownRoute: true is also reachable as its own collection, scoped to the parent by id — POST /blog-posts/:id/notes/search, gated by the parent’s own record-level view capability rather than one of its own. See A route scoped to the parent.
Operators
Section titled “Operators”A closed list — = != > >= < <= like not like in not in. Nothing else parses. in / not in require an array value; anything else is a 400.
Every field named in a filter, a sort or a select is checked against the resource’s own allow-list before it touches the database. Ask for something outside it and you get a 400, not a silently ignored field:
POST /blog-posts/search{ "limit": 999 }{ "error": { "status": 400, "key": "query.invalid", "message": "Invalid value for \"limit\". Allowed: 10, 15, 20.", "details": { "param": "limit", "allowed": [10, 15, 20] } }}To know a resource’s actual allow-lists ahead of time instead of guessing from a 400, see the Details page.
Response meta
Section titled “Response meta”{ "meta": { "channels": ["blogPost"], "page": 1, "limit": 15, "total": 3, "last_page": 1 } }channels is what a live UI subscribes to — see Realtime — to know when to refetch, instead of polling. page/limit/total/last_page are the real pagination window, not a truncated guess. next_cursor is the sort key of the last row plus its id as a tiebreaker, and is null on a short page (nothing follows) or when the response projects away a field the sort needs, or when the last row’s sort value is null (a nullable column cannot be keyed on, so a cursor naming one is refused with query.invalid; page those with page/limit). Hand it back as cursor to continue exactly after that row: a row inserted or deleted between two requests never shifts the next page, and total keeps counting the whole filtered set rather than what remains. Every paginated sort gets id appended, so rows tied on the sort key still have a strict order. A resource whose blueprint declares no pagination has no window to report: its meta carries total and "paginated": false, and data holds every match.
Capabilities in the response
Section titled “Capabilities in the response”Name capabilities in the body and each record grows a capabilities object describing what the current caller may do with it — no need to attempt an update just to find out it will fail:
POST /blog-posts/search{ "capabilities": ["update"] }{ "data": [ { "id": "cly8x7g9k0000abc123def456", "title": "Hello world", "capabilities": { "update": { "allowed": true, "scope": "own" } } } ], "messages": []}The keys a record can resolve are update and delete, plus restore on a resource with soft deletes and an attach<Relation> and detach<Relation> pair for each many-to-many relation (attachTags, detachTags). When capabilities are requested, meta.capabilities also carries the ones that belong to the collection rather than a record: create, and on a resource with soft deletes hardDelete and listTrashed. GET /describe lists the relations and says whether the resource has a bin, so a client knows which keys to ask for.
There is no view key: seeing the record in this response already answered that question. scope names which rule let the write through (own, team or all), and is absent when allowed is false. Use this to decide whether to render an edit button, not as a substitute for handling the 403 the server still returns if you ignore it.
The real generated handler, from examples/blog-post/blog-post.controller.ts — every resource gets the same shape, only the allow-lists change:
@Post('search')
@HttpCode(200)
@Capability(canViewAnyBlogPost)
@CacheableRead()
async search(
@Req() req: RequestWithUser,
@Body(new ZodValidationPipe(searchRequestSchema)) body: SearchRequestBody,
) {
const query = parseSearchRequest(body, BLOG_POST_CONTRACT);
const subject = subjectOf(req.user);
if (query.withTrashed || query.onlyTrashed) {
const trashedDecision = canListTrashedBlogPost(subject);
if (!trashedDecision.allowed) {
throw new CapabilityForbiddenException(trashedDecision);
}
}
const { records, total, matches } = await this.blogPosts.search(
subject,
query,
);
const capabilities = body.capabilities ?? [];
const select = query.select;
const project = (view: Record<string, unknown>) =>
select
? Object.fromEntries(
Object.entries(view).filter(([key]) => key in select),
)
: view;
const pageLimit = query.limit ?? 15;
const meta = {
channels: [BLOG_POST_SIGNAL_CHANNEL],
page: query.page ?? 1,
limit: pageLimit,
last_page: Math.max(1, Math.ceil(total / pageLimit)),
next_cursor: nextCursorFor(records, query.sorts, pageLimit),
total,
...(matches
? {
search: { matchLimit: matches.limit, truncated: matches.truncated },
}
: {}),
};
// A truncated match set makes total a floor rather than a count, so the
// response says which one it is instead of leaving the caller to trust a
// number that is quietly wrong.
const messages = matches?.truncated
? [
`Only the first ${matches.limit} full-text matches were counted, and more exist. The totals below are a floor, not a count -- narrow the search term to see the rest.`,
]
: [];
if (capabilities.length === 0) {
return ok(
records.map((record) =>
withIncludesAndAggregates(
project(toBlogPostView(record)),
record,
query,
),
),
meta,
messages,
);
}
return ok(
records.map((record) => {
const resolved = this.policy.recordCapabilities(subject, record);
return {
...withIncludesAndAggregates(
project(toBlogPostView(record)),
record,
query,
),
capabilities: Object.fromEntries(
Object.entries(resolved).filter(([key]) =>
capabilities.includes(key),
),
),
};
}),
{
...meta,
capabilities: this.policy.metaCapabilities(subject),
},
messages,
);
}parseSearchRequest is the one place that ever reads body.filters/body.sorts/body.selects — it checks every field against this resource’s own allow-list and builds the Prisma where fragment from the validated operator, never from the raw client value, before the controller sees any of it. See src/technical/http/list-query.ts for that logic — it is shared, not regenerated per resource.
Conditional requests
Section titled “Conditional requests”search, describe and the relation search routes answer with an ETag and Cache-Control: private, no-cache, with Vary: Authorization, Cookie. The ETag hashes the response body together with the caller’s tenant, user and impersonator, so two callers never share an entry even when a capability-scoped response happens to be identical. Send it back as If-None-Match and an unchanged response comes back as 304 with no body.