Skip to content
NewComposite primary keys3 min read

Browser

The browser can be either end of a UQL query. uql-orm/browser talks to your API over HTTP, and uql-orm/pglite skips the network by running Postgres in the tab.

uql-orm/browser consumes the REST API served by the HTTP core or the Express extension, using the same query syntax you write on the server. It is optional, and it is an HTTP client rather than a driver: something on the other end still holds the connection.

import { HttpQuerier } from 'uql-orm/browser';
import { User } from './shared/models/index.js';
const querier = new HttpQuerier('https://api.yourdomain.com/api');
const { data: users } = await querier.findMany(User, {
$select: { email: true },
$populate: { profile: { $select: { picture: true } } },
$where: { email: { $endsWith: '@domain.com' } },
$sort: { createdAt: 'desc' },
$limit: 10,
});
// typed User[], each with a typed profile

Entity classes are shared between backend and frontend, so the query type-checks identically on both sides.

Every wire operation has a typed method: findMany, findManyAndCount, findOne, findOneById, count, insertOne, insertMany, saveOne (upsert via PUT), saveMany, updateOneById, updateMany, deleteOneById, deleteMany. Responses are { data, count? }.

URLs derive from the shared CRUD_ROUTES contract in uql-orm/http, and a compile-time check guarantees the client covers every operation, so the mapping cannot drift from the server.

Failed requests throw a RequestError carrying the server’s message and the numeric HTTP status, so status-driven flows work without string matching:

import { RequestError } from 'uql-orm/browser';
try {
await querier.findMany(User, {});
} catch (err) {
if (err instanceof RequestError && err.status === 401) {
location.href = '/login';
}
}
// per instance: defaults that per-call options override
const querier = new HttpQuerier('/api', {
headers: { Authorization: `Bearer ${session.token}` },
});
// per call: abort/timeout, headers, and `silent` to skip the notification bus
await querier.findMany(User, q, {
signal: AbortSignal.timeout(120_000),
headers: { Authorization: `Bearer ${session.token}` },
});

Build one scoped instance per server-side request rather than reusing a module-level one, so a token never leaks across requests.

For non-CRUD endpoints (/api/payments/checkout, …), the typed helpers get, post, put, patch, remove and query are exported too, sharing the same envelope, headers, notifications and RequestError.

Opt in to send read queries in the request body instead of the URL, which sidesteps URL-length limits on large $where/$populate:

const querier = new HttpQuerier('/api', { readMethod: 'QUERY' });

findOne, findMany and count then use QUERY; writes and by-id reads keep their canonical methods. The default stays GET because cross-origin QUERY needs a CORS preflight and some proxies still drop the method. The server accepts both at once, so this is a per-client switch.

A small pub/sub bus (on) emits start, success, error and complete per request, which is enough for a global spinner in a vanilla app. Libraries that already track loading state do not need it: pass { silent: true }. See the TanStack Query recipe, where the serializable query doubles as the cache key.

PGlite is Postgres compiled to WASM, and idb:// is its browser dataDir, so PgliteQuerierPool runs in a page with no server behind it. Same entities, same queries, same migrations as the server, because it reports itself as the postgres dialect:

import { PgliteQuerierPool } from 'uql-orm/pglite';
import { User } from './shared/models/index.js';
const pool = new PgliteQuerierPool('idb://app');
const querier = await pool.getQuerier();
const users = await querier.findMany(User, {
$select: { id: true, email: true },
});

The database survives a reload: idb:// persists into IndexedDB.

Two things your bundler has to get right, each with its own failure:

  • Emit PGlite’s assets. It fetches pglite.wasm, initdb.wasm and pglite.data from the page’s origin at runtime. When they 404, getQuerier throws Invalid FS bundle size.
  • Set a dated target. No browser implements decorators natively, so esnext leaves the syntax in and the page dies on Invalid or unexpected token before any of your code runs. Same requirement as on the server.

One connection means one transaction at a time, which for a single tab is rarely the limit it sounds like. PGlite has the rest.