Collections
A collection is one table you declare in a file. QUESTPIE reads that file and builds a typed CRUD object, REST routes, an admin screen and a client, then moves all four together when you change it.
You have written one collection. This page is the rest of the builder: which method to reach for, what each one changes, and what a second call to the same method does.
Declare it
Put a file under your collections/ directory. Import collection from
#questpie/factories, not from questpie. The generated factory knows
which modules you enabled, so module field types such as f.richText() appear
on f. The bare export from questpie sees only the built-in types.
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({
title: f.text(255).label("Title").required(),
slug: f.text(120).required(),
body: f.textarea(),
published: f.boolean().default(false),
}))
.title(({ f }) => f.title)
.access({ read: true })
.options({ versioning: true });Then build the surface and create the table:
questpie generate # registers posts, writes the types, routes and client
questpie push # creates the table in your dev databasequestpie dev watches and regenerates as you edit. In production you write a
migration with questpie migrate:generate instead of pushing.
What that file produced
| Surface | Where it shows up |
|---|---|
| Typed CRUD | app.collections.posts, and ctx.collections.posts inside hooks, routes and jobs |
| REST | /posts and its sub-routes, under your handler's base path, /api in the starters |
| Admin | /admin/collections/posts, a list and a form, from @questpie/admin |
| OpenAPI | An entry in the spec and the Scalar reference at /api/docs, from @questpie/openapi |
| Client | client.collections.posts, the same calls carrying the same types |
const { docs } = await app.collections.posts.find({
where: { published: true },
});
const post = await app.collections.posts.create({
title: "Hello",
slug: "hello",
});`find()` hands back a page, not an array
It resolves to { docs, totalDocs, totalPages, page, limit, pagingCounter, hasPrevPage, hasNextPage, prevPage, nextPage }. Your rows are on docs.
The chain
collection(name) returns a builder. Every method returns a new builder, and
the app calls .build() for you when it registers the collection. The last
column says what a second call to the same method does.
| Method | What it declares | Called twice |
|---|---|---|
.fields(({ f }) => …) | Columns, validation, admin controls | Adds, overrides by key |
.title(({ f }) => f.title) | Which field labels a row | Replaces |
.options(opts) | Timestamps, soft delete, versioning, schema | Replaces |
.access(rules) | Who may read, create, update, delete | Replaces |
.hooks(hooks) | Code that runs around each operation | Appends |
.indexes(({ table }) => […]) | Drizzle indexes and constraints | Replaces |
.validation(opts) | Tuning for the generated Zod schemas | Replaces |
.searchable(config) | What the search adapter indexes | Replaces |
.upload(opts) | Turns the collection into a file store | Replaces |
.collaborative(config) | One collaborative document per row | Replaces |
.set(key, value) | Arbitrary state, the extension seam | Replaces that key |
.merge(other) | Folds in another builder of the same name | Both apply |
.admin(), .list(), .form(), .actions() and .preview() are missing from
that table on purpose. @questpie/admin contributes them through .set(), so
they exist only when that module is on, and the admin docs cover them.
Fields add up, the rest replace
.fields() merges by key so you can start from a collection someone else
wrote. Redeclaring a key replaces that field cleanly, dropping its old
localized and relation state. .hooks() collects each stage into an array so a
module can add its own without knocking out yours. Everything else takes the
last call, which is why .options() wants one call with every option in it.
Merging two builders
.merge(other) folds a second builder of the same name into this one. Fields,
options, relations and access are merged per key and the argument wins, hooks
from both sides concatenate, and title, searchable, upload and
validation take the argument's value wherever it has one. So merge first and
chain your own calls after it, which is how you extend a collection a module
ships. See Modules.
Indexes
.indexes() receives the built columns and returns Drizzle index values. It is
stored as a callback and run at build time.
import { uniqueIndex } from "questpie/drizzle-pg-core";
collection("posts").indexes(({ table }) => [
uniqueIndex("posts_slug_idx").on(table.slug),
]);Searchable
A collection is indexed only after an explicit .searchable(…) call.
.searchable({}) is the safe title-only projection. Content, metadata and
facets are opt-in, because a shared index cannot enforce per-field access
inside a projected value. See Search.
Types come from the fields
$infer reads the row, insert and update shapes off the definition, so no hand
written interface can drift from the table. It is type-only, and its runtime
value is an empty object.
type Post = typeof posts.$infer.select; // the full row, including _title
type NewPost = typeof posts.$infer.insert;
type PostPatch = typeof posts.$infer.update; // Partial<NewPost>Rows carry _title, computed on read from the field you named in .title()
and resolved through localization and virtuals. Name no title field and it
falls back to id. It labels the row in the admin, and find({ search })
matches against it. Narrow columns to a list of your own and it drops out
with everything else you did not ask for.
Registration
Codegen scans collections/. The name you pass to collection() is required,
and it is the key, with kebab-case turned to camelCase. So
collection("blog-posts") registers as app.collections.blogPosts whatever
the file is called. Named and default exports both work, and one file may
export several collections, each becoming its own entity.
questpie add collection my-thing writes the file and runs codegen for you.
Run questpie add --list to see the other scaffolds.
An unset access rule is not public
A rule you never wrote falls back to your app's defaultAccess, and past that
to requiring a session, so anonymous reads are rejected. Write read: true to
open a collection on purpose.
Where each topic lives
| Topic | Page |
|---|---|
| Timestamps, soft delete, versioning, publish stages | Options |
| Every CRUD method and the REST route behind it | Reading and writing |
| Collections that store file bytes | Uploads |
Every f.* type and its column | Fields |
| Foreign keys, hydration, nested writes | Relations |
| Rules that allow, deny or filter | Access control |
| Each hook signature and the transaction lifecycle | Hooks |
| Tuning the generated Zod schemas | Validation |
| Rows that leave the list but not the table | Soft delete |
| Two people editing one field | Collaborative documents |
| Extending a collection a module ships | Modules |
| One row instead of many | Globals |
Next
Globals is the same builder for the things your
site has exactly one of, with get and update in place of the CRUD set.