Skip to content
NewComposite primary keys5 min read

Quick Start

UQL is type-safe to the leaf with nothing to generate: entities are plain classes, and every query is serializable (portable JSON) that runs unchanged on the server, in the browser, or over the network.

An orange game piece standing on the START circle of a board game

Install the core and your preferred driver:

Terminal window
npm install uql-orm pg # or mysql2, better-sqlite3, mongodb, etc.

Node 24+, Bun, Deno, or an edge runtime like Cloudflare Workers, plus TypeScript 5.2 or newer. UQL is ESM only: there is no require() path, so a CommonJS project cannot consume it.

tsconfig.json
{
"compilerOptions": {
// `nodenext` or `preserve`: the resolver has to read `exports` to find `uql-orm/postgres`
"module": "nodenext",
// any dated target works (es2022+), never `esnext`: it leaves decorator syntax untransformed
"target": "es2025",
// `await using` needs `AsyncDisposable`, which no dated target's default lib carries
"lib": ["esnext"]
}
}

Behind a bundler, "module": "preserve" (TypeScript 5.4+) or "moduleResolution": "bundler" does the same job. Next.js needs no changes at all; Vite’s template pins lib to ES2022, so add esnext there. Node also needs "type": "module" in package.json to run the compiled output, which Bun, Deno and bundler-driven frameworks do not.


An entity, a pool, and a query:

entities.ts
import { v7 as uuidv7 } from 'uuid';
import { Entity, Id, Field } from 'uql-orm';
@Entity()
export class User {
@Id({ type: 'uuid', onInsert: uuidv7 })
id?: string;
@Field({ type: String, unique: true })
email?: string;
@Field({ type: String })
name?: string;
}
// uql.config.ts
import type { Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import { User } from './entities.js';
const pool = new PgQuerierPool({
host: 'localhost',
user: 'postgres',
password: 'password',
database: 'uql_app',
});
export default { pool, entities: [User] } satisfies Config;
export { pool };
// app.ts
import { pool } from './uql.config.js';
import { User } from './entities.js';
// A single operation goes straight on the pool: it acquires a connection, runs, and releases it.
await pool.insertMany(User, [
{ email: 'ada@uql-orm.dev', name: 'Ada' },
{ email: 'alan@uql-orm.dev', name: 'Alan' },
{ email: 'grace@example.com', name: 'Grace' },
]);
// Same for reads.
const users = await pool.findMany(User, {
$select: { id: true, name: true },
$where: { email: { $endsWith: '@uql-orm.dev' } },
$limit: 10,
});
console.log(users); // -> Ada and Alan; Grace's email doesn't match

The User table has to exist before that insert runs; step 3 generates it from the entity class.

Build the pool once per process and import it everywhere; nothing connects until the first query. Which driver, how big, and when to close it are on Pool.

Every operation lives on both the pool and the querier. A pool call is one unit of work on its own connection; pool.withQuerier (or pool.transaction when it must be all-or-nothing) pins one connection across several. See pool vs. querier.


An entity class and a table are two views of one schema, and UQL generates either from the other. You just wrote the entity, so generate the table - on an empty database, one command is enough:

Terminal window
npx uql-migrate sync

That reads the entities in your uql.config.ts, diffs them against the database, and creates what is missing. Add --dry-run to read the DDL before it runs. Keep sync for development; once the data matters, generate:entities writes the same diff to a file you review in the pull request and can roll back.

Starting from tables that already exist runs the other direction: generate:from-db writes the entity classes for you.