QUESTPIE
Reference

Collection API

Complete reference for collection() builder methods.

collection(name)

Creates a new collection builder. Collections map to database tables and provide typed CRUD operations.

import { collection } from "#questpie/factories";

const posts = collection("posts")
  .fields(({ f }) => ({
    title: f.text(255).required(),
    content: f.richText().localized(),
    status: f.select(["draft", "published"]),
    author: f.relation("author_id").to("users"),
  }))
  .title(({ f }) => f.title)
  .options({ timestamps: true, softDelete: true })
  .hooks({
    beforeChange: async ({ data, operation }) => {
      if (operation === "create") {
        data.slug = slugify(data.title);
      }
    },
  })
  .access({
    read: true,
    create: ({ session }) => !!session,
    update: ({ session }) => session?.user?.role === "admin",
    delete: ({ session }) => session?.user?.role === "admin",
  });

Builder Methods

All methods are chainable and return a new builder instance (immutable pattern).

MethodDescription
.fields()Define data fields using the field builder
.title()Set the display title field
.options()Configure timestamps, soft delete, versioning
.hooks()Attach lifecycle hooks
.access()Set access control rules
.searchable()Configure full-text search indexing
.validation()Override or refine auto-generated validation schemas
.upload()Enable file upload support with storage integration
.indexes()Define database indexes and constraints
.set()Generic extension point for plugins

With @questpie/admin installed, the following extension methods are also available:

MethodDescription
.admin()Admin UI metadata (label, icon)
.list()Configure the list view
.form()Configure the form view
.preview()Configure live preview
.actions()Define custom record actions

.fields(callback)

Define collection fields using the field builder. The callback receives { f } where f is a proxy providing all registered field factories.

.fields(({ f }) => ({
  title: f.text(255).required(),
  slug: f.text(255).unique(),
  content: f.richText().localized(),
  publishedAt: f.date(),
  views: f.number().default(0),
  featured: f.boolean().default(false),
  tags: f.object(),
  author: f.relation("author_id").to("users"),
  category: f.relation("category_id").to("categories"),
}))

The f proxy includes all built-in field types plus any custom types contributed by modules. After codegen runs, module-contributed fields (like f.richText() or f.blocks()) become available automatically.

.title(callback)

Set which field is used as the record display title. The callback receives a field proxy where accessing a property returns its name.

.title(({ f }) => f.title)

The title field generates a computed _title column on every record, used for display in admin lists and relation selectors.

.options(config)

Configure collection-level options.

.options({
  timestamps: true,      // Adds createdAt, updatedAt (default: true)
  softDelete: true,      // Adds deletedAt, records are soft-deleted
  versioning: true,      // Creates a versions table for history
})

Versioning with Workflow

Versioning can include a publishing workflow with stage transitions:

.options({
  versioning: {
    enabled: true,
    maxVersions: 50,
    workflow: {
      stages: ["draft", "review", "published"],
      initialStage: "draft",
    },
  },
})

When workflow is set, versioning is automatically enabled.

.hooks(config)

Attach lifecycle hooks. Each hook can be a single function or an array. Multiple .hooks() calls merge their hooks.

.hooks({
  beforeValidate: ({ data, operation }) => {
    if (operation === "create" && !data.slug) {
      data.slug = slugify(data.title);
    }
  },
  beforeChange: ({ data, operation, db, session }) => {
    if (operation === "create") {
      data.createdBy = session?.user?.id;
    }
  },
  afterChange: async ({ data, original, operation, queue }) => {
    if (operation === "update" && original?.status !== data.status) {
      await queue.statusChange.publish({ id: data.id });
    }
  },
  beforeDelete: async ({ data, db }) => {
    // Prevent deletion if has dependencies
    const deps = await db.select().from(orders)
      .where(eq(orders.productId, data.id));
    if (deps.length > 0) {
      throw new Error("Cannot delete: has active orders");
    }
  },
  afterDelete: async ({ data, queue }) => {
    await queue.cleanup.publish({ id: data.id });
  },
  afterRead: ({ data }) => {
    data.displayName = `${data.firstName} ${data.lastName}`;
  },
})

See Hooks API for the full hook reference.

.access(config)

Set access control rules. Each rule can be a boolean or a function receiving the request context.

.access({
  read: true,
  create: ({ session }) => !!session,
  update: ({ session, id }) => {
    if (session?.user?.role === "admin") return true;
    return session?.user?.id === id;
  },
  delete: ({ session }) => session?.user?.role === "admin",
})

.searchable(config)

Configure full-text search indexing with BM25 ranking and trigrams.

.searchable({
  content: (record) => `${record.title} ${extractText(record.content)}`,
  metadata: (record) => ({ status: record.status, category: record.categoryId }),
})

The content function extracts plain text for full-text indexing. The metadata function provides structured data for faceted filtering.

.validation(options)

Override or refine the auto-generated Zod validation schemas. By default, schemas are generated from field definitions automatically.

.validation({
  exclude: { id: true, createdAt: true },
  refine: {
    email: (s) => s.email("Invalid email address"),
    age: (s) => s.min(0, "Age must be positive"),
  },
})

.upload(options)

Enable file upload support. Automatically adds storage fields (key, filename, mimeType, size, visibility), extends the output type with { url: string }, and registers upload HTTP routes.

collection("media")
  .fields(({ f }) => ({
    alt: f.text(),
    folder: f.text(),
  }))
  .upload({
    visibility: "public",       // "public" | "private"
    maxSize: 10_000_000,        // 10 MB
    allowedTypes: ["image/*", "application/pdf"],
  })

Upload collections get additional CRUD methods: upload() and uploadMany().

.indexes(callback)

Define database indexes and constraints. The callback receives { table } for column references.

.indexes(({ table }) => [
  uniqueIndex().on(table.slug),
  index().on(table.createdAt),
  index().on(table.status, table.publishedAt),
])

.set(key, value)

Generic extension point used by plugins to attach metadata without monkey-patching.

.set("admin", { icon: { type: "icon", props: { name: "ph:article" } } })
.set("adminList", { view: "collection-table", columns: ["title", "status"] })

Admin Extension Methods

These methods are available when @questpie/admin is installed.

.admin(callback)

Set admin UI metadata for the collection.

.admin(({ c }) => ({
  label: { en: "Blog Posts", sk: "Blogove prispevky" },
  icon: c.icon("ph:article"),
  hidden: false,
}))

.list(callback)

Configure the admin list view.

.list(({ v, f }) => v.collectionTable({
  columns: [f.title, f.status, f.author, f.createdAt],
  defaultSort: { field: "createdAt", order: "desc" },
  filters: [f.status, f.author],
}))

.form(callback)

Configure the admin form view.

.form(({ v, f }) => v.collectionForm({
  fields: [
    f.title,
    f.slug,
    {
      type: "section",
      label: "Content",
      fields: [f.content, f.excerpt],
    },
  ],
  sidebar: {
    position: "right",
    fields: [f.status, f.author, f.publishedAt, f.featured],
  },
}))

.preview(config)

Enable live preview for the collection.

.preview({
  enabled: true,
  position: "right",
  defaultWidth: 400,
  url: ({ record }) => `/blog/${record.slug}`,
})

.actions(config)

Define custom record actions.

.actions([
  {
    name: "publish",
    label: "Publish",
    handler: async ({ id, collections }) => {
      await collections.posts.update(id, { status: "published" });
    },
  },
])

Type Inference

Collections provide type inference through the $infer property:

type PostSelect = typeof posts.$infer.select;
type PostInsert = typeof posts.$infer.insert;
type PostUpdate = typeof posts.$infer.update;

On this page