QUESTPIE
GuidesBuild a plugin

Builder methods

Add a typed method to collection(), global() or any field. You declare where the value lands and what type it takes. The generator writes the method into your app's own factories file.

View markdown

Your plugin needs per-collection settings. You could ask for a config file listing collection names. A method on collection() is better. It sits where the collection is declared, and the compiler checks the argument.

Declare the method

A registry extension is one small object. This one adds .retention().

src/questpie/server/plugin.ts
registries: {
	collectionExtensions: {
		retention: {
			stateKey: "retention",
			imports: [{ name: "RetentionConfig", from: "@acme/retention" }],
			configType: "RetentionConfig",
		},
	},
},
FieldWhat it does
stateKeyThe key the value lands under on the builder
configTypeThe parameter type. Leave it out and the method takes any
importsWhat configType needs, added to the generated factories file
defaultsFolded under the user's config as { ...defaults, ...config }

Use it

Run questpie generate. The method is now on the collection you import from #questpie/factories, typed, with autocomplete.

src/questpie/server/collections/events.ts
import { collection } from "#questpie/factories";

export const events = collection("events")
	.fields(({ f }) => ({ name: f.text(120).required() }))
	.retention({ days: 30 });

Read it back

app.collections is the CRUD API. The collection itself comes from getCollectionConfig, and your value sits on its state under your stateKey.

const events = app.getCollectionConfig("events");
const config = (events.state as { retention?: RetentionConfig }).retention;

The cast is not optional. The generated method returns CollectionBuilder<TState> unchanged, so the state type never gains your key. The core reads .list() and .form() the same way.

That is the whole contract. Your plugin declares the method, the app author calls it, and your runtime code reads one key. Nothing in the core knows what retention means.

One name, one owner

Extensions merge by name across every plugin on a target. Two plugins declaring retention means the later one wins and the earlier method is gone. Prefix anything that is not obviously yours.

Methods that take a callback

.list() and .form() take a function instead of an object. That is how the app author reaches f.title rather than typing "title". Set isCallback and name the parameters your callback receives.

acmeList: {
	stateKey: "acmeList",
	configType: "(ctx: { f: Record<string, string> }) => AcmeListConfig",
	isCallback: true,
	callbackContextParams: ["f"],
},

Each name in callbackContextParams is looked up in callbackParams, on your extension first and then on the target. Every entry points at a real exported factory, never an inline string.

callbackParams: {
	f: { factory: "createFieldNameProxy", from: "questpie/builders" },
},

createFieldNameProxy is the core one, and it is why f.title resolves to "title" inside a callback. Declare your own factory when you need a different proxy.

The other registries

collectionExtensions has four siblings on the same target.

RegistryWhat it adds
globalExtensionsThe same method on global()
fieldExtensionsA method on every f.*() instance
singletonFactoriesA typed identity wrapper for a convention file
builderFactoriesA factory needing the merged field definitions

A field extension is declared the same way but stores its value elsewhere. It lands under extensions on the field state, keyed by your stateKey. A field type's own methods write to the top level, so the two never overwrite each other.

A singleton factory is how appConfig() and authConfig() exist. It generates export function myConfig<T extends MyConfig>(config: T): T, so a convention file gets a type without importing one.

A builder factory is how block() exists. Some builders need the app's merged field definitions at construction, and only the generated file has those. That is why you import block from #questpie/factories and not from a package.

Where each topic lives

TopicPage
Every field on a registry extensionPlugins
Your own f.* typeField types
The collection() chain as it shipsCollections
What the generator writes and whereCodegen

Next

Building a plugin is the page these methods hang off. It builds the plugin object itself, then ships it.

On this page