> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# HTTP (any framework)

> Serve UQL entities over HTTP from any framework with the framework-agnostic transport core.

Source: https://uql-orm.dev/http

`uql-orm/http` turns your entities into a REST API without tying you to a web framework. It owns the route table, the request/response envelopes, query (de)serialization, querier lifecycle, transactions, and authorization hooks. Adapters are thin bindings on top:

- `createFetchHandler` returns a web-standard `(request: Request) => Promise<Response>`.
- [`uql-orm/express`](https://uql-orm.dev/express.md) binds the same core to Express 5.
- `createRequestHandler` takes a normalized request object, for frameworks that are neither (see [Fastify](https://uql-orm.dev/fastify.md)).

This whole layer is optional; UQL works as a standalone ORM without it. The query you serve here is the one you write on the server and send from the browser: [one query, every transport](https://uql-orm.dev/querying/querier.md#the-same-query-every-transport).

## Mounting

```ts
import { createFetchHandler } from 'uql-orm/http';
import { pool } from './uql.config.js';
import { Post, User } from './shared/models/index.js';

const handler = createFetchHandler({ pool, include: [User, Post] });
```

| Runtime | Mount | `basePath` |
| - | - | - |
| [Hono](https://uql-orm.dev/hono.md), [Elysia](https://uql-orm.dev/elysia.md) | `app.mount('/api', handler)` | no, `mount` strips the prefix |
| `Bun.serve` | `{ fetch: handler }`, or `{ routes: { '/api/*': handler } }` under a prefix | only for the wildcard form |
| `Deno.serve`, Cloudflare Workers | `Deno.serve(handler)` / `export default { fetch: handler }` | no, it serves the root |
| [Next.js](https://uql-orm.dev/nextjs.md), [Astro](https://uql-orm.dev/astro.md), [React Router](https://uql-orm.dev/react-router.md), [TanStack Start](https://uql-orm.dev/tanstack-start.md) | one catch-all route, per recipe | yes |
| Nitro / h3 v1 | `fromWebHandler(handler)` in a catch-all | yes |

Where the table says `basePath` is required, pass the prefix you mounted at: `createFetchHandler({ pool, include: [User, Post], basePath: '/api/uql' })`. File-based routers match a prefix without rewriting the URL, so the handler has to be told to ignore it. On h3 v2 the bridge becomes `defineEventHandler((event) => handler(event.req))`, since `event.req` is a `Request` there.

## The pool

The handler runs on the [pool](https://uql-orm.dev/pool.md) you give it. One module usually builds it, registers the entities, and is what [`uql-migrate`](https://uql-orm.dev/migrations.md) reads:

```ts title="uql.config.ts"
import type { Config } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
import { Post, User } from './shared/models/index.js';

export const pool = new PgQuerierPool({
  connectionString: process.env.DATABASE_URL,
});

export default { pool, entities: [User, Post] } satisfies Config;
```

Pass a function to pick one per request, which is how a single deployment serves a database per tenant. It runs after `getContext`, with the adapter’s request and the context that resolved from it, and only for requests that reach a statement: a `pre` hook that throws never triggers the lookup.

```ts
const handler = createFetchHandler({
  include: [User, Post],
  getContext: (request) => ({ tenantId: tenantOf(request) }),
  pool: (_request, { tenantId }) => poolFor(tenantId as string),
});
```

## Wire protocol

For an entity named `User` (paths derive from the kebab-cased class name):

| Operation | Method | Endpoint | Body | Description |
| - | - | - | - | - |
| `findMany` | `GET` | `/user` | | List records; add `?count=true` for the total count. |
| `findOne` | `GET` | `/user/one` | | First record matching the query. |
| `count` | `GET` | `/user/count` | | Count matching records. |
| `findOneById` | `GET` | `/user/:id` | | One record by primary key. |
| `insertOne` | `POST` | `/user` | object | Insert a record. |
| `insertMany` | `POST` | `/user/many` | array | Insert many records. |
| `saveOne` | `PUT` | `/user` | object | Insert or update (upsert). |
| `saveMany` | `PUT` | `/user/many` | array | Insert or update many. |
| `updateMany` | `PATCH` | `/user` | object | Bulk partial update of records matching `$where`. |
| `updateOneById` | `PATCH` | `/user/:id` | object | Partial update by primary key. |
| `deleteOneById` | `DELETE` | `/user/:id` | | Delete by primary key. |
| `deleteMany` | `DELETE` | `/user` | | Bulk delete of records matching the query. |

Delete routes [soft-delete](https://uql-orm.dev/entities/soft-delete.md) by default where the entity has the field; `?hardDelete=true` overrides. `GET` endpoints take the [serializable query](https://uql-orm.dev/querying/querier.md) as JSON strings in the query string (`$skip` and `$limit` as numbers). Writes run in a transaction, reads acquire and release a querier, `HEAD` mirrors `GET`, and malformed JSON is a `400`, as is a `$where` that is not an object (`$where=[1,2]`; name the key instead, `{"id":[1,2]}`).

Responses use one envelope everywhere:

```jsonc
// success
{ "data": ..., "count": 3 }

// error (status mirrors `code`)
{ "error": { "message": "forbidden", "code": 403 } }
```

A database constraint failure gets its own status and a generic message, never the driver’s: `409` `Conflict` for a duplicate or a missing referenced row, `400` `Bad Request` for a not-null or check violation ([error kinds](https://uql-orm.dev/querying/errors.md)). An update against a stale [version](https://uql-orm.dev/entities/optimistic-locking.md) is a `409` too, with a message naming the entity and both versions; a payload carrying no version at all, or a `save` or `upsert` of a versioned entity, is a `400`. Everything else is a `500`.

The route table is exported as `CRUD_ROUTES`, its keys compile-time constrained to `UniversalQuerier` method names, so the adapters, the [browser client](https://uql-orm.dev/browser.md), and your own tooling share one source of truth.

## HTTP QUERY (RFC 10008)

`QUERY` is an alternate transport for the three read routes (`/user`, `/user/one`, `/user/count`): same semantics as `GET`, but the query travels in the body, so large `$where`/`$populate` never hit URL-length limits.

The core, the Express adapter, Node and Bun all support it. The host framework has to route it too: mounts and wildcards that forward the raw request do (Hono, Elysia, `Bun.serve`, an Astro `ALL` export or [`src/fetch.ts`](https://uql-orm.dev/astro.md#auto-generated-crud)), routers keyed to named verbs do not (Next.js route handlers, React Router’s loader/action split, `fastify.all`). It stays opt-in in the [browser client](https://uql-orm.dev/browser.md) because a cross-origin `QUERY` needs a CORS preflight and some proxies still drop unknown methods.

## Authorization hooks

Hooks run before the querier is touched, can be async, receive the adapter’s native request as `context`, and abort by throwing (a numeric `status` becomes the HTTP status):

```ts
const handler = createFetchHandler({
  pool,
  include: [User],
  async pre({ context }) {
    if (!(await authenticate(context.headers.get('authorization')))) {
      throw Object.assign(new Error('unauthorized'), { status: 401 });
    }
  },
  preSave(ctx) {
    ctx.body = { ...(ctx.body as object), updatedAt: Date.now() };
  },
});
```

| Hook | Lifecycle | Use case |
| - | - | - |
| `pre` | Before every operation. | Logging, auditing, global validation. |
| `preSave` | Before `POST`, `PUT`, `PATCH`. | Injecting `creatorId`, sanitization. |
| `preFilter` | Before `GET`, `DELETE`. | Query shaping, forcing soft-delete. Not for tenant isolation. |
| `post` | After the operation (post-commit). | Response shaping: strip secrets, derive presentation fields. |

The hook context also carries `meta`, `op` and `method`, so one hook can branch per entity or operation. `post` receives the mutable success envelope, which covers sanitization a forced `$select`/`$exclude` cannot express:

```ts
const handler = createFetchHandler({
  pool,
  include: [User],
  post({ meta }, envelope) {
    if (meta.entity === User) {
      envelope.data = (envelope.data as User[]).map(
        ({ password, ...rest }) => ({
          ...rest,
          hasPassword: !!password,
        }),
      );
    }
  },
});
```

## Tenant scoping

Folding a tenant id into `$where` from `preFilter` is not isolation: it is not AND-merged, does not reach joined relations, and does not fail closed. Pass `getContext` instead and declare a `security` [filter](https://uql-orm.dev/querying/filters.md). `getContext` runs the whole request inside `withContext`, so every query it makes is scoped and a client cannot opt out of it:

```ts
const handler = createFetchHandler({
  pool,
  include: [Invoice],
  getContext: (req) => ({ tenantId: authenticate(req).tenantId }), // verified session / JWT
});
```

```ts
import { Entity, Filter } from 'uql-orm';

@Filter('tenant', {
  where: (ctx) =>
    ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined,
  security: true,
})
@Entity()
export class Invoice {}
```

See [Multi-tenancy](https://uql-orm.dev/multi-tenancy.md).

## Composing with custom routes

The handlers cover single-entity CRUD only: anything else 404s from `createFetchHandler` and falls through via `next()` in the Express adapter, so both styles share one prefix. Read-modify-write logic, multi-entity transactions, aggregations, raw SQL, file uploads, streaming and third-party side effects stay in routes you write.

> **Minification**
>
> Entity routes derive from `entity.name` at runtime. If you minify your server bundle, keep class names (`keep_classnames` in terser, `keepNames` in esbuild), or routes and client URLs change.
