Skip to content

Procedures

Procedures are the core building block of Velox TS APIs. They define type-safe endpoints with input validation, output schemas, and handlers.

import { procedures, procedure, resourceSchema, resource } from '@veloxts/velox';
import { z } from '@veloxts/velox';
// Define resource schema with field visibility
const UserSchema = resourceSchema()
.public('id', z.string().uuid())
.public('name', z.string())
.authenticated('email', z.string().email())
.admin('internalNotes', z.string().nullable())
.build();
export const userProcedures = procedures('users', {
getUser: procedure()
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.id }
});
return resource(user, UserSchema.public); // returns { id, name }
}),
});

Define the input validation schema:

.input(z.object({
id: z.string().uuid(),
includeDeleted: z.boolean().optional(),
}))

The .output() method accepts both plain Zod schemas and tagged resource views for context-dependent output projection. The Resource API lets you define field visibility levels (public, authenticated, admin) once and project data based on access level.

const UserSchema = resourceSchema()
.public('id', z.string())
.public('name', z.string())
.authenticated('email', z.string())
.admin('internalNotes', z.string().nullable())
.build();
// Tagged view — one-liner projection
getPublicProfile: procedure()
.output(UserSchema.public) // projects { id, name }
.query(async ({ input, ctx }) => {
return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
}),
// Automatic projection with guards
getProfile: procedure()
.guard(authenticated)
.output(UserSchema.authenticated) // projects { id, name, email }
.query(async ({ input, ctx }) => {
return ctx.db.user.findUniqueOrThrow({ where: { id: input.id } });
}),

See Resource API for tagged views, automatic projection, manual projection, collections, and type inference.

For domain-specific roles beyond the default three levels, defineAccessLevels() lets you define arbitrary level names and named groups. The same tagged view pattern applies — ArticleSchema.reviewer, ArticleSchema.moderator, and so on. See Custom Access Levels for details.

Define the handler function:

  • .query() - For read operations (GET)
  • .mutation() - For write operations (POST, PUT, DELETE)
.query(({ input, ctx }) => ctx.db.user.findUniqueOrThrow({
where: { id: input.id }
}))

Add authorization guards:

.guard(authenticated)
.guard(hasRole('admin'))

Add middleware:

.use(rateLimitMiddleware)
.use(loggingMiddleware)

Attach a policy action for resource-level authorization:

import { PostPolicy } from '../policies/post.js';
.guard(authenticated)
.policy(PostPolicy.update)
.mutation(async ({ input, ctx }) => { ... })

See Policies for defining policies and actions.

Declare which domain errors a procedure can throw. This enables type-safe error narrowing on the client:

import { InsufficientBalance, AccountLocked } from '../errors.js';
.throws(InsufficientBalance, AccountLocked)
.mutation(async ({ input, ctx }) => { ... })

See Business Logic — Domain Errors for details.

Wrap the handler in a database transaction. Rolls back automatically on error:

.transactional()
.mutation(async ({ input, ctx }) => {
// ctx.db is scoped to the transaction
})

See Business Logic — Transactions for options.

Run a pre-handler pipeline of steps, with optional revert-on-failure:

.through(reserveInventory, chargePayment, sendConfirmation)
.mutation(async ({ input, ctx }) => { ... })

Steps execute in order. If one fails, earlier steps revert. See Business Logic — Pipelines for defineStep and .onRevert().

Emit a domain event after the handler succeeds:

.emits(OrderPlaced, (output) => ({ orderId: output.id }))
.mutation(async ({ input, ctx }) => { ... })

See Business Logic — Domain Events for event definitions.

Add a post-handler hook that runs after the response is sent:

.useAfter(async ({ input, output, ctx }) => {
await ctx.analytics.track('order.placed', { orderId: output.id });
})

See Middleware — Post-Handler Hooks for details.

Override REST endpoint generation:

.rest({ method: 'POST', path: '/auth/login' })

See REST Overrides for full options.

Every builder method in declaration order:

MethodPurposeGuide
.input(schema)Input validationabove
.output(schema)Output schema or resource viewabove, Resource API
.guard(guard)Request-level authorizationGuards
.policy(action)Resource-level authorizationPolicies
.use(middleware)Pre-handler middlewareMiddleware
.throws(...errors)Declare domain errorsBusiness Logic
.transactional(opts?)Wrap handler in DB transactionBusiness Logic
.through(...steps)Pre-handler pipelineBusiness Logic
.emits(Event, mapper?)Emit domain event on successBusiness Logic
.rest(config)Override REST routeREST Overrides
.query(handler) / .mutation(handler)Terminal handlerabove
.useAfter(hook)Post-handler hookMiddleware

The handler receives { input, ctx }:

.query(({ input, ctx }) => {
// input: Validated input data
// ctx.db: Prisma client
// ctx.request: Fastify request
// ctx.reply: Fastify reply
// ctx.user: Authenticated user (if using auth)
})

Group related procedures with procedures():

export const postProcedures = procedures('posts', {
listPosts: procedure()...,
getPost: procedure()...,
createPost: procedure()...,
updatePost: procedure()...,
deletePost: procedure()...,
});

The first argument ('posts') becomes:

  • The REST resource name: /api/posts
  • The tRPC namespace: trpc.posts.listPosts

Types flow automatically through the Resource API:

// Backend - Define resource schema
const UserSchema = resourceSchema()
.public('id', z.string())
.public('name', z.string())
.authenticated('email', z.string())
.build();
const createUser = procedure()
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.create({ data: input });
return resource(user, UserSchema.authenticated);
});
// Frontend (via tRPC or client)
// Input is typed: { name: string; email: string }
// Output is typed: { id: string; name: string; email: string }

Procedures are automatically discovered from src/procedures/:

src/index.ts
import { veloxApp, rest, discoverProcedures } from '@veloxts/velox';
const app = await veloxApp({ port: 3030 });
const collections = await discoverProcedures('./src/procedures');
app.routes(rest([...collections], { prefix: '/api' }));