defineQuery
defineQuery(projection, options) declares a read function over a projection. CQRS read path — queries never read actors.
Signature
function defineQuery<TState, TSchema extends ZodTypeAny, TResult>(
projection: ProjectionDefinition<TState>,
options: QueryOptions<TState, TSchema, TResult>,
): QueryDefinition<TState, TSchema, TResult>QueryOptions
interface QueryOptions<TState, TSchema, TResult> {
name: string
description?: string
input: TSchema
execute: (state: TState, input: z.output<TSchema>) => TResult | Promise<TResult>
// Studio-aware
slo?: { p95LatencyMs?: number }
cacheable?: boolean // hint — opt-in cache adapters honor it
}Example
export const submissionsByStudent = defineQuery(SubmissionsByStudent, {
name: "submissions.by-student",
description: "Avi's submission history, newest first.",
input: z.object({
studentId: z.string(),
status: z.enum(["submitted", "under-review", "graded"]).optional(),
limit: z.number().default(20),
}),
slo: { p95LatencyMs: 50 },
cacheable: true,
execute: (state, { studentId, status, limit }) => {
let items = state.byStudent[studentId] ?? []
if (status) items = items.filter((s) => s.status === status)
return items.slice(0, limit)
},
})Invocation
As an HTTP route
// Register the query as a handler on the App:
createApp({ appName: "submissions", registry: defineRegistry({ handlers: [submissionsByStudent] }) })
// Wire it under an HTTP route binding — the query IS the handler:
app.wire(
get("/submissions", { query: submissionsByStudent.input }),
submissionsByStudent,
)The HTTP adopter parses query params against the schema (?studentId=avi&status=graded&limit=10).
From a handler
const reviewSubmission = defineAction({
name: "submissions.review",
input: ReviewSubmissionInput,
handler: async (ctx) => {
const { input } = ctx
const history = await ctx.query(submissionsByStudent, { studentId: input.studentId })
// …
},
})ctx.query passes the envelope's tenant automatically. No manual threading.
Directly via runtime
const result = await app.runtime.query("submissions.by-student", { studentId: "avi" })For tests, scripts, the CLI. Specify tenant as the third arg (runtime.query(name, input, tenant)).
Why queries instead of "just call the projection store"
Three reasons:
- Validation — query inputs go through zod, same as action inputs. Wrong shape returns a 400 (HTTP) or throws (in-process).
- HTTP wire ergonomics — wire a query under a route binding and the transport parses query-params through its schema automatically.
- Studio — queries appear in the canvas as teal stickies; observed latency from the telemetry stream scores against
slo.p95LatencyMs.
Caching
cacheable: true is a hint, not enforcement. Future cache middleware (@nwire/cache) can read this and cache the result keyed by (name, input, tenant). With no cache middleware mounted, the flag is a no-op.
If your query reads a slow projection store and the data doesn't change often, set cacheable: true and add the cache middleware later — no code changes when you do.
Studio rendering
Queries appear as smaller teal stickies on the EventStorm canvas, anchored to their projection. The edge from projection → query is automatic. SLO scorecard shows declared p95LatencyMs vs observed.