Query Pipeline
piq executes queries in ordered stages. Each stage narrows or reshapes the result set, and the chain shows which work happens before execution.
Cost Model
The query builder uses four stages:
scan()narrows the candidate set with path-derived parameters. This is the cheapest stage.filter()checks resolved data such as frontmatter fields. This stage requires loading candidates.select()declares which fields to extract and which namespaces to materialize.exec()orstream()executes the query and returns rows.
Each stage can increase work. The chain makes that cost visible before the query runs.
Path-Driven Access Patterns
Path patterns are the first index. Structuring paths around your most common access dimension keeps queries in the cheap scan phase.
// Path encodes year, so scan narrows without reading files
path: '{year}/{slug}.md'
.scan({ year: '2024' })If frequently-filtered data lives only in frontmatter, every query pays the filter cost across a larger candidate set.
Flat Results
Select paths are namespaced such as params.slug and frontmatter.title, but the final row is flattened by the last segment.
.select('params.slug', 'frontmatter.title')
// { slug, title }Key Collision Detection and Aliases
If final segments collide, TypeScript fails the select at compile time.
// compile-time error because both selections end in 'title'
.select('params.title', 'frontmatter.title')Fix collisions with aliases:
.select({
routeTitle: 'params.title',
postTitle: 'frontmatter.title',
})Filter Operators
Filter values match exactly by default. Pass { contains: '...' } to substring-match a string field instead; on string-array fields it matches when any element contains the needle. Matching is case-sensitive.
.filter({ status: 'published', subject: { contains: 'Rule 30' } })Sorting and Limiting
sort() orders results by a concrete dot-path. limit() caps the row count after sorting.
// The five most recent published posts
.filter({ status: 'published' })
.sort('params.date', 'desc')
.limit(5)
.select('params.slug', 'frontmatter.title')- Direction is
'asc'by default. - Chain
sort()calls for multi-key sorts; earlier keys take precedence. - A sort key does not need to appear in the select. The resolver materialises it for ordering, then the row is flattened from the select paths alone.
- Wildcard paths such as
frontmatter.*are not valid sort keys. - Numbers and dates compare numerically; everything else compares as strings. Missing values sort last.
Execution Notes
select()is required beforeexec(). Missing select throws at runtime.- Repeated
scan()orfilter()calls merge constraints; later values win for overlapping keys. single().exec()returns the first row orundefined.single().execOrThrow()throws if zero rows are returned.