Skip to content
NewComposite primary keys5 min read

tRPC

UQL queries are plain JSON, so they pass through tRPC procedures without any adapter. Nothing to install beyond your existing tRPC v11 setup: procedures call the querier pool directly.

import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import type { Query, Type } from 'uql-orm/type';
import { pool } from './uql.config.js';
import { User } from './shared/models/index.js';
const t = initTRPC.create();
function entityRouter<E extends object>(entity: Type<E>) {
return t.router({
findMany: t.procedure
.input(z.custom<Query<E>>()) // declares the input type; no cast, no per-procedure schema
.query(({ input }) => pool.findMany(entity, input)),
insertOne: t.procedure
.input(z.custom<E>())
.mutation(({ input }) => pool.insertOne(entity, input)),
});
}
export const appRouter = t.router({
user: entityRouter(User),
});

On the client the whole query is typed end to end, filters, sorting and nested relation loading alike, and reaches the server as plain JSON with no per-procedure schema to keep in sync:

const users = await trpc.user.findMany.query({
$select: { id: true, name: true },
$where: { status: 'active', email: { $endsWith: '@domain.com' } },
$populate: {
posts: { $select: { title: true }, $where: { published: true }, $limit: 5 },
},
$limit: 10,
});
// typed User[], each with a typed posts: Post[]

A procedure that writes more than one row wraps its statements in pool.transaction and runs each on the callback’s querier: a pool.x call inside there takes a second connection, so it lands outside the transaction.

Prefer tRPC when you want per-procedure contracts and its client tooling; prefer the HTTP core when you want zero-boilerplate CRUD for many entities. They compose fine in one app, and the query object is identical either way: one query, every transport.