Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

Browser

A query can run from the browser two ways: sent to your API with uql-orm/browser, or against Postgres running in the tab with uql-orm/pglite.

HttpQuerier calls the API that uql-orm/http serves. Share your entity classes with the browser and the query type-checks exactly as it does on the server:

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' } },
$limit: 10,
});
// typed User[], each with a typed profile

Each wire route has its method: findMany, findManyAndCount, findManyPage, findOne, findOneById, count, exists, insertOne, insertMany, saveOne, saveMany, updateOneById, updateMany, deleteOneById and deleteMany. Each resolves to { data, count? }.

The query travels as JSON, so the client takes a WireQuery, which has no room for sql fragments. A sql fragment or a binary value throws before anything is sent; handle those in a route of your own.

Rows come back as the entity holds them, a Date field a Date and a bigint exact, read by the entity’s metadata, which importing its class registers. So they are not plain JSON: a cache that persists them, such as a query persister or dehydrated SSR state, needs a serializer that keeps a Date and a bigint.

A failed request throws a RequestError with the server’s message and HTTP status:

import { RequestError } from 'uql-orm/browser';
try {
await querier.findMany(User, {});
} catch (err) {
if (err instanceof RequestError && err.status === 401)
location.href = '/login';
}

Headers set on the instance apply to every call, and per-call options add to them:

const querier = new HttpQuerier('/api', {
headers: { Authorization: `Bearer ${session.token}` },
});
await querier.findMany(
User,
{ $limit: 20 },
{ signal: AbortSignal.timeout(10_000) },
);

During server rendering, create one instance per request so a token never leaks into another user’s request. If the server names routes with entityPath, pass the same function here. For endpoints of your own, the helpers get, post, put, patch, remove and query use the same envelope, headers and RequestError.

Set readMethod: 'QUERY' to send reads in the request body instead of the URL, which avoids URL-length limits on large queries:

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

The reads without an id then use QUERY; everything else keeps its method. It is off by default because a cross-origin QUERY needs a CORS preflight and some proxies drop it.

on reports each request’s start, success, error and complete, enough for a global spinner, and returns an unsubscribe function. Each notification carries the call’s options, so a listener can skip the calls made with { silent: true }, as the TanStack Query recipe makes them:

import { on } from 'uql-orm/browser';
const off = on(({ phase, opts }) => {
if (!opts?.silent) {
document.body.classList.toggle('loading', phase === 'start');
}
});

PGlite is Postgres compiled to WASM. Give PgliteQuerierPool an idb:// directory and the database lives in IndexedDB, surviving reloads. It is the postgres dialect, so your entities, queries and migrations are the server’s:

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

PGlite has one connection, so one transaction runs at a time. PGlite covers the rest.