Runtime Schemas
Some schemas only exist once the process is running: a CMS where an admin creates a content type through a UI, a tenant whose columns are a row in someone else’s table. The shape is data, not source, so nothing about it can be written down before the code is compiled.
Nothing in uql reads a TypeScript type 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: a column per field, which is the shape a CMS keeps its own schema in. */type ContentType = { name: string; fields: { name: string; type: ColumnType }[];};
async function registerContentType(contentType: ContentType) { // Every key is a column, so every value is a scalar; the key is declared beside them, which is what // types 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;}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 to key by — nothing constructs it, and rows come back as plain objects. What you pass to a querier is that class, exactly like a hand-written entity.
Applying it to the database
Section titled “Applying it to the database”sync({ entity }) is the runtime path: one entity, one table.
- The table does not exist — one existence check, then
CREATE TABLE IF NOT EXISTS, and no catalogue read. This is the common case, an admin creating a content type, and instances racing the same save settle instead of colliding. - The table exists — a column diff needs the columns, so this reads them, then applies the same additive changes a full
syncwould: 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 after it was constructed is included with no restart — and one pinned to an explicit list still syncs an entity outside it, which is every content type created after startup.
Editing and deleting a content type
Section titled “Editing and deleting a content type”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));removeEntity(entity) is also what a deleted content type needs: an otherwise append-only registry would keep it, and its table in every diff, for the life of the process.
What survives at compile time
Section titled “What survives at compile time”The row type is whatever the class you minted says, and the one above says the honest thing: a bag of columns with a typed key. Queries are checked against it — $where, $sort, $populate and projections all take a column by name, and every operator stays available, since a column typed as every scalar has none to rule out. No column name can be checked, because none was known when the code compiled.
Declaring id!: string beside the index signature is what types the key: an insert reports string | undefined rather than every scalar at once, so findOneById takes what the insert returned. It is allowed there because string is one of the types the signature admits. Where the key is not called id, name it with the idKey brand:
class Content { declare [idKey]?: 'pk'; pk!: string; [column: string]: Scalar;}To get the column names back, generate an interface per content type from the same stored definition — 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;}What this deliberately does not do
Section titled “What this deliberately does not do”- Change a column. Retyping, renaming or dropping one is refused by a sync and stays a migration, on every engine — SQLite cannot express it at all.
- Lock across instances.
CREATE TABLE IF NOT EXISTSsettles the race that shows up when two instances answer the same admin save; anything stronger is your coordination to add. - Validate rows against the content type. The database enforces what the DDL says; the rest is your application’s, and an ORM guessing at it would be a second, weaker schema.