QUESTPIE
Schema

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.

View markdown

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.

src/questpie/server/collections/posts.ts
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 database

questpie dev watches and regenerates as you edit. In production you write a migration with questpie migrate:generate instead of pushing.

What that file produced

SurfaceWhere it shows up
Typed CRUDapp.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
OpenAPIAn entry in the spec and the Scalar reference at /api/docs, from @questpie/openapi
Clientclient.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.

MethodWhat it declaresCalled twice
.fields(({ f }) => …)Columns, validation, admin controlsAdds, overrides by key
.title(({ f }) => f.title)Which field labels a rowReplaces
.options(opts)Timestamps, soft delete, versioning, schemaReplaces
.access(rules)Who may read, create, update, deleteReplaces
.hooks(hooks)Code that runs around each operationAppends
.indexes(({ table }) => […])Drizzle indexes and constraintsReplaces
.validation(opts)Tuning for the generated Zod schemasReplaces
.searchable(config)What the search adapter indexesReplaces
.upload(opts)Turns the collection into a file storeReplaces
.collaborative(config)One collaborative document per rowReplaces
.set(key, value)Arbitrary state, the extension seamReplaces that key
.merge(other)Folds in another builder of the same nameBoth 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

TopicPage
Timestamps, soft delete, versioning, publish stagesOptions
Every CRUD method and the REST route behind itReading and writing
Collections that store file bytesUploads
Every f.* type and its columnFields
Foreign keys, hydration, nested writesRelations
Rules that allow, deny or filterAccess control
Each hook signature and the transaction lifecycleHooks
Tuning the generated Zod schemasValidation
Rows that leave the list but not the tableSoft delete
Two people editing one fieldCollaborative documents
Extending a collection a module shipsModules
One row instead of manyGlobals

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.

On this page