tRPC
A UQL query is plain JSON, so it can be a tRPC procedure’s input with no adapter. Use tRPC for per-procedure contracts, and the HTTP handler for CRUD across many entities; both fit in one app. Written for tRPC v11.
Router
Section titled “Router”import { initTRPC } from '@trpc/server';import type { EntityData, Type, WireQuery } from 'uql-orm';import { z } from 'zod';import { User } from './shared/models/index.js';import { pool } from './uql.config.js';
const t = initTRPC.create();
function entityRouter<E extends object>(entity: Type<E>) { return t.router({ findMany: t.procedure .input(z.custom<WireQuery<E>>()) .query(({ input }) => pool.findMany(entity, input)), insertOne: t.procedure .input(z.object({ data: z.custom<EntityData<E>>() })) // tRPC cannot type a bare generic input .mutation(({ input }) => pool.insertOne(entity, input.data)), });}
export const appRouter = t.router({ user: entityRouter(User) });export type AppRouter = typeof appRouter;The client gets the whole query typed, nested relations included:
import { createTRPCClient, httpBatchLink } from '@trpc/client';import type { AppRouter } from './router.js';
const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: '/trpc' })],});
const users = await trpc.user.findMany.query({ $select: { id: true, name: true }, $populate: { posts: { $select: { title: true }, $limit: 5 } }, $limit: 10,});// typed User[], each with a typed posts: Post[]Validating inputs
Section titled “Validating inputs”z.custom<T>() declares a type and checks nothing at runtime. For procedures an untrusted client can call, accept a few validated arguments and build the query on the server.
For tenant isolation, run the procedure body inside withContext({ tenantId: ctx.session.tenantId }, () => ...), with the id from a verified session, and declare a security filter. Unlike a $where added by hand, the filter reaches every relation and fails closed. See Multi-tenancy.