Runtime Schemas
Some schemas exist only at runtime, such as a CMS content type an admin creates in a UI. uql reads no TypeScript types at runtime, so the imperative API can register a class you create on the fly, and sync({ entity }) gives it a table:
import { defineEntity, type ColumnType, type Scalar } from 'uql-orm';import { Migrator } from 'uql-orm/migrate';
type ContentType = { name: string; fields: { name: string; type: ColumnType }[];};
async function registerContentType(contentType: ContentType) { // Named after the content type, so its errors read like any other entity's. const entity = { [contentType.name]: class { id!: string; [column: string]: Scalar; }, }[contentType.name];
defineEntity(entity, { name: contentType.name, fields: { id: { type: 'uuid', isId: true }, ...Object.fromEntries( contentType.fields.map((field) => [field.name, { type: field.type }]), ), }, });
await new Migrator(pool).sync({ entity }); return entity;}The class is only an identity for the registry: nothing constructs it, and rows come back as plain objects. Pass it to a querier like any other entity.
Translate your UI’s own vocabulary (text, longtext, money) to a SQL column type as you build the fields. Columns every content type shares (createdBy, a tenant key) belong on a base named with extends, since a runtime class cannot extend it in code.
Applying it to the database
Section titled “Applying it to the database”If the table does not exist, sync({ entity }) runs CREATE TABLE IF NOT EXISTS after one existence check, so two instances handling the same admin save both finish. If it exists, uql applies what a full sync would: in the default safe mode, that adds new columns and skips, with a log line, any column retyped or removed. Change existing columns with a migration.
A Migrator built without an entities option reads the registry live, so entities registered later are included without a restart. One built with an explicit list still syncs an entity passed to sync({ entity }).
uql takes no lock across instances beyond IF NOT EXISTS, and does not validate rows against the content type: the database enforces what the DDL says.
Editing and deleting a content type
Section titled “Editing and deleting a content type”Registering the same content type again creates a second class mapping the same table. Remove the previous entity first:
import { removeEntity } from 'uql-orm';
const previous = registered.get(contentType.name);if (previous) removeEntity(previous);registered.set(contentType.name, await registerContentType(contentType));Call removeEntity(entity) for a deleted content type too, or its table stays in every diff for the life of the process.
Typing
Section titled “Typing”Queries are checked against the class. With an index signature, $where, $sort, $populate and projections take any column by name, so names cannot be checked. Declaring id!: string types the key: an insert reports its id as string | undefined instead of any scalar, and findOneById takes it. If the key has another name, brand it:
import { idKey, type Scalar } from 'uql-orm';
class Content { declare [idKey]?: 'pk'; pk!: string; [column: string]: Scalar;}To check column names, generate an interface per content type with npx uql-migrate types, which writes one for every registered entity, and declare the class with those columns instead of an index signature.