QUESTPIE

Context

One object reaches every handler you write. It carries the database, the entity APIs, the caller's identity and your own services, all typed by codegen.

View markdown

Most pages in these docs destructure ctx and move on. This one says what the object is, what is on it, and why the shape shifts between call sites.

One object, every handler

A hook, a route, a job, an access rule and a service factory each take a single argument. That argument is the context. QUESTPIE builds a fresh one per operation, so nothing on it is shared global state.

afterChange: async ({ data, db, queue, services }) => {
	await queue.reindex.publish({ id: data.id });
},

Codegen types it from your own app. ctx.collections.posts knows your fields. ctx.services.blog knows what your factory returned. A typo fails to compile.

What is on it

One function, extractAppServices, sets this group. It runs for every context in every app, so these keys are always there.

KeyWhat it is
appThe built app instance.
dbThe Drizzle client. Inside a write it is the open transaction.
sessionThe Better Auth session, or null.
collectionsTyped CRUD, one entry per collection.
globalsTyped CRUD, one entry per global.
queuePublish a job.
emailSend mail, or send a template.
storageThe storage adapter.
kvThe key-value adapter.
searchThe search adapter.
realtimeThe realtime service.
loggerThe app logger.
tTranslate a message key.
servicesYour own services, default namespace.

Two more appear only once something resolves them. principal is the discriminated identity, user, oauth or system. Only its oauth variant carries scopes. actor is the Human or Agent authority for collaboration.

Keys a module adds

A service declared with namespace: null lands top-level on the context. Two come from the core module, so they are typed in every app.

KeyWhat it is
channelsTyped realtime channels.
crdtThe CRDT server API.

workflows works the same way but ships in @questpie/workflows. Install no module and the key is gone from both the type and the object.

Three keys exist only at runtime

extractAppServices also sets auth, executor and observability. Codegen leaves all three off AppContext, so destructuring one fails to compile. Reach them through ctx.app.

`app` and `ctx` are not the same surface

app.collections and app.email exist. app.services does not. Your own services only ever reach you through a context.

It changes shape by call site

A route, a hook and a row access rule all start from the keys above. Each one then adds its own.

Call siteAdds
Route handlerinput, params, request, locale
Hookdata, original, operation, locale, accessMode, onAfterCommit
Row access ruledata, input, locale, request
Job handlerpayload, locale, dispatchId, idempotencyKey
Service factoryNothing

A job handler starts from a smaller set. Codegen drops app, crdt, principal and actor from it.

A hook in a bulk write also gets isBatch, recordIds, records and count. A service factory only gets session when its lifecycle is "request". A singleton is built at app start, and there is no caller yet. Its ctx.services resolves only the default namespace, though the generated type is wider.

A route's own access rule is the odd one out. It gets locale, request and params instead of data and input.

The field rule is a different object

access.fields rules do not get the app context at all. The typed context is four keys: user, doc, operation and req. No db, no collections, no services. The runtime object also carries principal, actor and your appConfig({ context }) keys, none of them typed. See Field-level access.

Access mode

normalizeContext resolves most context keys in the same order. What you passed wins. Then the ambient scope. Then the default.

QUESTPIE keeps the ambient scope in AsyncLocalStorage. A route handler, a job handler and a seed each open one. On the CRUD side only find, findOne and create do. A nested call inherits what its caller had.

// inside a route handler serving a logged-in user
const posts = await collections.posts.find({ where: { published: true } });
// accessMode is "user" and session is the caller's. You passed neither.

An omitted context is not system mode

Inside a request an omitted accessMode inherits "user", so your access rules run. A job or a seed sits inside an explicit "system" scope. A bare script inherits nothing and takes the "system" default. Both skip your rules.

An HTTP request starts at "user". The adapter builds the request context that way unless the transport is explicitly trusted. A job handler runs inside an explicit "system" scope, so it does not matter who published the job.

Override one key and the rest still inherit:

// still inside the request: session comes from the ambient scope
await collections.audit.create({ data }, { accessMode: "system" });

Outside a handler, getContext() reads the ambient scope and throws when there is none. createContext() from #questpie builds a fresh one for a script.

Adding your own keys

Three seams, in rising order of scope.

Per request. appConfig({ context }) runs once per HTTP request and its return is merged flat onto the context. The keys arrive optional, because the resolver never runs for a job or a script. This is the multi-tenancy seam, and App config covers it in full.

Per app. Give a service a namespace. null puts it top-level. A string puts it under that name.

src/questpie/server/services/audit.ts
export default service({
	namespace: null, // ctx.audit, not ctx.services.audit
	create: (ctx) => ({
		log: (event: string) => ctx.logger.info(event),
	}),
});

Custom namespaces are singleton only. A "request" lifecycle service with one throws at build time, and the message tells you to use null or "services".

Per package. A plugin augments the interface, and every handler picks the key up:

declare global {
	namespace Questpie {
		interface AppContext {
			billing: BillingClient;
		}
	}
}

Framework key names are taken

session, db, locale and accessMode are reserved. The framework sets them after the merge, so a resolver value never lands. collections and globals are reserved for the opposite reason. They would shadow the entity APIs. Outside production a reserved key logs a warning.

  • Reading and writing for the context argument every CRUD method takes.
  • Services for what lands on ctx.services and when it is built.
  • Hooks for the lifecycle behind data and operation.
  • Access control for what accessMode: "user" turns on.

On this page