App config
config/app.ts holds four app-wide settings, locales, fallback access, global hooks and the per-request context resolver. Each one merges differently when a module already set it.
| Key | Type | Merged across modules by |
|---|---|---|
locale | LocaleConfig | last file wins |
access | AppDefaultAccess | last file wins |
hooks | GlobalHooksInput | concatenation |
context | ContextResolver | last file wins |
Every key is optional, and so is the file. The scaffolded starters ship no
config/app.ts at all.
locale
Configures the content-localization layer that backs localized fields.
import { appConfig } from "questpie/app";
export default appConfig({
locale: {
locales: [
{ code: "en", label: "English", fallback: true },
{ code: "sk", label: "Slovenčina" },
],
defaultLocale: "en",
fallbacks: { "en-GB": "en" },
},
});locales is an array, or a function returning one, sync or async. Each entry
takes code, label, fallback and flagCountryCode. defaultLocale is used
when a request names none. fallbacks maps one code to another.
A request naming a locale that is not in the list is not rejected. It falls to
fallbacks, then to defaultLocale.
Write no locale at all and the app has one locale, en.
access
The rule an operation falls back to when the collection or global declares none.
Each entry is a boolean or a function. A function may return true, false, or
a where object that filters rows.
access: {
read: true,
create: ({ session }) => Boolean(session),
}AppDefaultAccess takes seven keys, and the runtime reads six of them.
| Key | Chain |
|---|---|
read, create, update, delete | Collection rule, then this map, then "require a session" |
serve | Collection serve, then collection read, then this map, then allow |
introspect | Collection introspect, then this map, then "any CRUD allowed" |
transition | Nothing reads it. A transition falls back to update instead. |
A CRUD operation nobody wrote is never public. serve is the one chain that
ends in an allow. Files marked private still need a signed token on top of it.
This key replaces, it does not merge
Your file's access overwrites the module's whole map, not the operations you
named. adminModule pulls in the starter, which requires a session for all
four CRUD operations. Write { read: true } alone and the other three fall
through to the built-in session check.
The function receives ResolvedAppDefaultAccessContext. It carries db,
session and the collections, plus data, input, locale and request. It
is leaner than the context a collection's own .access() rule gets. Routing the
full context through here would build a type cycle in the generated app.
hooks
Callbacks that run across every collection or every global, so a cross-cutting concern lands once instead of in each file.
hooks: {
collections: {
include: ["posts", "pages"],
afterChange: ({ collection, data, operation }) => {},
},
globals: {
afterChange: ({ global, data }) => {},
},
}collections and globals each take one entry object. include and exclude
scope it to named slugs, and exclude is applied after include. Omit both and
the hook runs everywhere.
| Target | Stages available |
|---|---|
collections | beforeChange, afterChange, beforeDelete, afterDelete, beforePurge, afterPurge, beforeTransition, afterTransition |
globals | beforeChange, afterChange, beforeTransition, afterTransition |
Put several stages on the one entry object rather than reaching for a second entry. Hooks concatenate across modules, so yours never displaces theirs.
You are not the first hook in the queue
The core module registers global hooks of its own for realtime capture, search indexing and scheduled transitions. Yours run alongside them.
context
Runs once per HTTP request. Its return is merged flat into the request context.
From there it reaches every access rule, hook, route handler, field access rule
and getContext() call. This is the seam for multi-tenancy.
context: async ({ request, session, collections }) => {
const tenantId = request.headers.get("x-tenant-id");
if (tenantId && session?.user) {
const member = await collections.tenant_members.findOne({
where: { tenant: tenantId, user: session.user.id },
});
if (!member) throw new Error("No access to this tenant");
}
return { tenantId };
},The resolver gets request, session and db. It also gets the whole
system-mode service surface. That means collections, globals, kv, queue,
logger, t and your own services, all typed by codegen.
Calls inside it run in system mode, with access rules bypassed. The resolver is the trusted derivation step, so it is also the place to reject a request.
It only runs when a request is present. Jobs, seeds and scripts build their context without one, so nothing the resolver returns is there.
Rules the return value has to follow
The return type is what types the context downstream, so it has to be an object.
appConfig() rejects a resolver returning only a primitive or only null.
session ? { role } : null passes, because one arm is an object.
Prefer an explicit object literal over a value you inferred and then narrowed. Downstream the keys arrive as optional, so read them with that in mind.
Some key names are taken
Return a framework key such as session, db, locale or request and the
framework overwrites it downstream. Return collections or globals and you
shadow the typed entity APIs instead. Outside production each one logs a
warning naming the key.
Why access and hooks vanish from the type
appConfig() is identity at runtime, but its return type keeps only locale
and context. access and hooks are erased to opaque storage.
That is deliberate. Their function parameters embed the merged app context, and
carrying that back into the generated index collapses the whole augmentation.
Both are still fully typed where you write them. You just cannot read them back
off typeof appConfigFile.
TypeScript
import type {
AppConfigInput, // { locale, access, hooks, context }
AppDefaultAccess,
LocaleConfig,
ContextResolver,
} from "questpie";ContextResolver<T> is generic over the object your resolver returns.
Runtime options
Every key runtimeConfig() accepts, the four shapes a database connection can take, what the storage object allows, and which QUESTPIE_* variables fill the gaps.
Plugin config files
Every file in config/ becomes one key on the app's config bucket. A plugin claims its own by declaring a single discover pattern, with no change to framework code.