Skip to content
NewFrom Mongoose to SQL6 min read

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.

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.

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.

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.