Skip to content

defineQuery

defineQuery(projection, options) declares a read function over a projection. CQRS read path — queries never read actors.

Signature

ts
function defineQuery<TState, TSchema extends ZodTypeAny, TResult>(
  projection: ProjectionDefinition<TState>,
  options: QueryOptions<TState, TSchema, TResult>,
): QueryDefinition<TState, TSchema, TResult>

QueryOptions

ts
interface QueryOptions<TState, TSchema, TResult> {
  name: string
  description?: string
  schema: 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

ts
export const submissionsByStudent = defineQuery(SubmissionsByStudent, {
  name: "submissions.by-student",
  description: "Avi's submission history, newest first.",
  schema: 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

ts
defineModule("submissions", {
  queries: [submissionsByStudent],
  routes: defineRoute({
    "GET /submissions": submissionsByStudent,
  }),
})

The HTTP wire automatically parses query params against the schema (?studentId=avi&status=graded&limit=10).

From a handler

ts
defineHandler(reviewSubmission, async (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

ts
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:

  1. Validation — query inputs go through zod, same as action inputs. Wrong shape returns a 400 (HTTP) or throws (in-process).
  2. HTTP wire ergonomicsdefineRoute({ "GET /x": myQuery }) mounts it with the right query-param parsing automatically.
  3. 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.

See also

MIT licensed.