Concepts

piq's design is built around a few core ideas. Understanding these will help you get the most out of the library.

Cost-Awareness

The API makes resolution cost visible. The layers aren't implementation detail—they're the API contract.

When you call .scan(), you're doing path enumeration. It's cheap—no file contents are read. When you call .filter(), you're reading frontmatter from each file that passed the scan. When you .select() body fields, you're parsing markdown.

This is explicit by design. You know what you're paying for.

Design Patterns Upfront

Like DynamoDB, piq rewards designing your access patterns into your data structure. The query harvests structure created at write time.

// Good: year in path, filterable without I/O
fileMarkdown({ path: '{year}/{slug}.md' })
piq.from('posts').scan({ year: '2024' })  // Fast - just glob pattern

// Less efficient: year only in frontmatter
piq.from('posts').scan({}).filter({ year: '2024' })  // Must read every file

Put high-cardinality, frequently-filtered fields in your path pattern where enumeration can extract them for free.

Flat Results

Select uses dotted paths, but results are flat. The final segment of each path becomes the property name:

.select('params.slug', 'frontmatter.title', 'body.html')
// Result: { slug: string; title: string; html: string }

If you need custom names or have collisions, use the object form:

.select({
  postSlug: 'params.slug',
  postTitle: 'frontmatter.title'
})
// Result: { postSlug: string; postTitle: string }

Relationships

piq doesn't do joins. Relationships are your responsibility.

Fetching a post then its author is one waterfall—acceptable:

const post = await getPost(slug)
const author = await getAuthor(post.authorId)

Fetching 100 posts then 100 separate author queries is N+1—restructure or batch:

// Bad: N+1
for (const post of posts) {
  const author = await getAuthor(post.authorId)
}

// Better: batch fetch authors
const authorIds = posts.map(p => p.authorId)
const authors = await getAuthors(authorIds)

Type Safety

Invalid queries are type errors, not runtime surprises. TypeScript catches:

  • Selecting fields that don't exist
  • Filtering on non-existent frontmatter properties
  • Scanning with invalid path parameters
  • Collision detection when two paths have the same final segment
// ERROR: 'title' appears in both paths
.select('params.title', 'frontmatter.title')

// FIX: use object form to alias
.select({ paramTitle: 'params.title', fmTitle: 'frontmatter.title' })