Skip to content
UQL
NewWhat a rename breaks: six TypeScript ORMs4 min read

Runtime Schemas

Some schemas only exist at runtime: a CMS content type an admin creates through a UI, or a tenant whose columns are rows in another table.

UQL reads no TypeScript types at runtime, so this needs no separate API: the imperative one registers a class you mint yourself, column by column.

import {
defineEntity,
removeEntity,
type ColumnType,
type Scalar,
} from 'uql-orm';
import { Migrator } from 'uql-orm/migrate';
/** What the admin UI stored: one column per field. */
type ContentType = {
name: string;
fields: { name: string; type: ColumnType }[];
};
async function registerContentType(contentType: ContentType) {
// Every value is a scalar column; `id` is declared to type an insert's id. 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 }]),
),
},
});
// One entity, one table: a new one costs an existence check and a `CREATE TABLE IF NOT EXISTS`.
await new Migrator(pool).sync({ entity });
return entity;
}

Columns every content type carries (createdBy, a tenant key) belong on a base named with extends, which a minted class has no way to extend.

If your UI keeps its own vocabulary (text, longtext, money), translate it to a column type as you build the fields. A SQL type states what the column is; String would leave its width to a default.

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 hand-written entity.

sync({ entity }) is the runtime path: one entity, one table.

  • The table does not exist: one existence check, then CREATE TABLE IF NOT EXISTS, with no catalogue read. Instances racing the same save settle instead of colliding.
  • The table exists: UQL reads its columns and applies the same additive changes a full sync would. A new column is added; a retyped or dropped one is refused and stays a migration.

A Migrator built without an entities option reads the registry live, so an entity registered later is included with no restart. One pinned to an explicit list still syncs an entity outside it.

Registering the same content type again mints a second class, and two entities then map one table. Keep the entity registerContentType returned and forget the previous one first:

const previous = registered.get(contentType.name);
if (previous) removeEntity(previous);
registered.set(contentType.name, await registerContentType(contentType));

A deleted content type needs removeEntity(entity) too; otherwise the registry keeps it, and its table in every diff, for the life of the process.

The row type is whatever the minted class says; the one above is a bag of scalar columns with a typed key. Queries are checked against it: $where, $sort, $populate and projections take any column by name, with every operator available. Column names cannot be checked, because none was known at compile time.

Declaring id!: string beside the index signature types the key: an insert reports string | undefined instead of every scalar, so findOneById takes what the insert returned. Where the key is not called id, name it with the idKey brand:

import { idKey, type Scalar } from 'uql-orm';
class Content {
declare [idKey]?: 'pk';
pk!: string;
[column: string]: Scalar;
}

To get column names back, generate an interface per content type (npx uql-migrate types writes one for every registered entity) and declare the class with those columns instead of an index signature:

class Post {
id!: string;
title?: string;
views?: number;
}
  • Change a column. Retyping, renaming or dropping one is refused by a sync and stays a migration on every engine.
  • Lock across instances. CREATE TABLE IF NOT EXISTS settles two instances answering the same admin save; anything stronger is yours to coordinate.
  • Validate rows against the content type. The database enforces what the DDL says; the rest belongs to your application.