Contexts
What your queries, mutations and actions receive at runtime.
Every function you hand FlowPanel — a widget query, a field's defaultValue, an
action's run, an adapter method — receives a context object. They all extend
RequestContext, so session, role and scope are always available.
RequestContext
Prop
Type
req is built from the real incoming headers even during a server render, so
cookie- and header-based auth works identically in pages and API routes.
QueryContext
Base context for reads. Widget queries and options resolvers receive this.
Prop
Type
ListQueryContext
What an adapter's list receives — everything needed to build one page of rows.
Prop
Type
searchFields is an allowlist taken verbatim from resource.options.search. An
adapter must never search columns outside it: that list is what keeps the search
box from becoming a way to probe columns the operator cannot otherwise see.
ItemQueryContext
Prop
Type
MutationContext
What an adapter's create, update, delete and restore receive.
Prop
Type
ActionContext
What every action's run receives.
Prop
Type
Filter values
Most filters arrive in ctx.filters as plain scalars. Two kinds arrive
structured, and ship with runtime guards so you can narrow unknown safely.
FilterRangeValue
Produced by daterange and numeric-range filters.
Prop
Type
FilterInValue
Produced by multiselect filters — match any of values.
Prop
Type
Narrowing them
import { isFilterInValue, isFilterRangeValue } from "@flowpanel/kit";
for (const [field, value] of Object.entries(ctx.filters)) {
if (isFilterRangeValue(value)) {
// value.gte / value.lte
} else if (isFilterInValue(value)) {
// value.values
} else {
// a scalar
}
}Raw SQL and counts
A widget query's WidgetContext — and a
detail tab's DetailTabContext — carries two reads the ORM cannot express
comfortably: ctx.sql and ctx.count.
ctx.sql
ctx.sql runs exactly the statement you write. It applies no tenant scope, no
access rule and no soft-delete filter — add those predicates yourself, or use
ctx.count and resource reads, which do.
metric("New signups", async (ctx) => {
const [row] = await ctx.sql<{ n: number }>`
select count(*)::int as n
from users
where created_at >= ${ctx.dateRange.from}
`;
return row?.n ?? 0;
});In a multi-tenant admin that means writing the predicate in the statement:
where tenant_id = ${ctx.session?.tenantId}. A resource read — ctx.count, a
table widget's resource, a related tab — is the scoped primitive; a raw
statement is not one.
ctx.sql is a tagged template. Every interpolated value is bound as a parameter,
so ${filter} from a search box can never change the statement — there is no
string-concatenating form of this API, on purpose. A Date binds as an ISO-8601
string and a bigint as decimal text, so the same value works on Postgres, MySQL
and SQLite. An array is a single value to the driver, and each adapter binds one
its own way — see the adapter reference before
writing an IN list.
Results come back as plain objects. Because a free-form statement carries no
column types to consult, every string shaped exactly like a zoneless
YYYY-MM-DD HH:mm:ss (with an optional T separator and fractional seconds) is
returned as a UTC Date — that is what the drivers returning them mean. A text
column of your own holding such a string is parsed too. Cast it in the query, or,
when select * gives you no place to cast, turn the pass off for the whole
adapter with parseDates: false — drizzleAdapter({ db, schema, parseDates: false })
or prismaAdapter({ prisma, provider, parseDates: false }). It defaults to true.
ctx.sql throws when the configured adapter implements no sql capability. The
error names the adapter kind; query through ctx.db instead.
ctx.count
ctx.count(resource, where) counts rows of a registered resource through the
same role, access and tenant-scope checks as any other cross-resource read, so a
resource the current session may not read counts 0 rather than leaking a total.
where takes the filter shapes ctx.filters uses, and soft-deleted rows are
excluded unless where says otherwise. A filter on a field the role may not read
counts 0, and so does an undefined filter value — a missing value narrows to
nothing rather than widening to every row.
stat("Open tickets", (ctx) => ctx.count("tickets", { status: "open" }));Request-scoped access
When you cannot thread a context through — a helper several calls deep, say — read it from AsyncLocalStorage instead of reaching for a global.
import { getRequestContext, tryGetRequestContext } from "@flowpanel/kit";
const ctx = getRequestContext(); // throws outside a request
const maybe = tryGetRequestContext(); // null outside a requestrunWithRequestContext(ctx, fn) establishes the scope; FlowPanel already wraps
every adapter call and every action run in it. This ambient state lives in
AsyncLocalStorage so concurrent requests do not share it.