Model Metadata

Read back what a model declares — table name, columns, relations, scopes — from your own code, without instantiating a repository.

What It Is For

defineModel stores a model's configuration in a registry as well as returning the class. MetadataStorage is the read side of that registry, and it is what the ORM itself uses internally — Repository reads your column definitions through it rather than off the class.

Reach for it when you are writing code that has to work across models it does not know about at compile time: an admin UI that renders a form per model, a schema inventory or audit script, a seed generator, a linter that checks every table has a primary key.

schema-report.ts
import { MetadataStorage } from "stabilize-orm";
import { User } from "./models/user";
MetadataStorage.getTableName(User); // "users"
Object.keys(MetadataStorage.getColumns(User)); // ["id", "email", "nationalId"]
MetadataStorage.isVersioned(User); // false
MetadataStorage.getSoftDeleteField(User); // "deletedAt", or null
MetadataStorage.getTimestamps(User); // the timestamps config, or {} if none

The Methods

Every method takes the model class — the value defineModel returned — and is static.

MethodReturns
getModelMetadata(model)the whole ModelConfig, or undefined
getTableName(model)the table name, or ""
getColumns(model)Record<string, ColumnConfig> keyed by property name
getRelations(model)Record<string, RelationConfig> keyed by relation property
getScopes(model)a record of scope name to scope function
getValidators(model)Record<string, string[]> — see the warning below
getSoftDeleteField(model)the soft-delete property name, or null
isVersioned(model)boolean
getTimestamps(model)a TimestampsConfig, or {}
getModelByTableName(tableName)the model class, or undefined
setModelMetadata(model, config)void — usually called by defineModel, not by you

Absent Models Do Not Throw

Nothing here raises on an unregistered model. Each getter falls back to a neutral value, which is convenient for the ORM internally and dangerous in a script that expects to be told it made a mistake:

silent-miss.ts
class NotAModel {}
MetadataStorage.getTableName(NotAModel); // "" — not undefined
MetadataStorage.getColumns(NotAModel); // {}
MetadataStorage.getRelations(NotAModel); // {}
MetadataStorage.isVersioned(NotAModel); // false
MetadataStorage.getSoftDeleteField(NotAModel); // null
MetadataStorage.getModelMetadata(NotAModel); // undefined — the one real signal

Note the asymmetry: getTableName answers "" and isVersioned answers false for a model that was never defined, so an empty string or a false is ambiguous between “declared this way” and “never registered”. When that distinction matters, test getModelMetadata(model) !== undefined instead.

is-defined.ts
import { MetadataStorage } from "stabilize-orm";
function isRegistered(model: Function): boolean {
return MetadataStorage.getModelMetadata(model) !== undefined;
}
if (!isRegistered(User)) {
throw new Error("User model was never registered — check the import path");
}

getValidators Is Narrower Than It Sounds

It reports required and unique, and nothing else

The name suggests every rule a column carries, but the method walks the columns and emits only two rule names. A column declared with minLength, maxLength, pattern or customValidator shows up as an empty array — so a form generated from this output silently drops those constraints.

typescript
// id: { type: DataTypes.INTEGER, required: true, unique: true }
// email: { type: DataTypes.STRING, minLength: 3, pattern: /@/ }
MetadataStorage.getValidators(User);
// {
// id: ["required", "unique"],
// email: [], // minLength and pattern are not reported
// }

Read the column configs directly if you need the rest — getColumns(model) carries the full ColumnConfig, including those fields. Enforcement at write time is unchanged; this only affects what the metadata reports. See Validation.

Property Names, Not Column Names

getColumns and getSoftDeleteField are keyed by the property name you wrote in defineModel. If a column is mapped to a different database name, the key is still the property, and the mapping lives inside the value:

mapping.ts
export const User = defineModel({
tableName: "users",
columns: {
id: { type: DataTypes.INTEGER, name: "user_id", required: true },
},
});
MetadataStorage.getSoftDeleteField(User); // the property name, not the DB column
Object.keys(MetadataStorage.getColumns(User)); // ["id"]
// The database name is on the value when overridden:
MetadataStorage.getColumns(User).id.name; // "user_id"

A repository resolves the mapping for you — this matters only when you are emitting SQL or building a schema report yourself, where a property name in a query will not match a renamed column.

Why It Survives a Duplicate ORM Copy

The registry is stored on globalThis under a Symbol.for(...) key rather than in a module-local variable, and setModelMetadata additionally mirrors the configuration onto static properties of the class. Both exist for the same failure mode: if the ORM ends up loaded twice — a duplicated dependency, mixed CJS and ESM resolution, a CLI process plus your app — two module instances would otherwise each hold their own registry, and models registered by one would look undefined to the other.

The globalThis registry is the authoritative copy. getModelMetadata consults it first and only falls back to the static mirror, which is written best-effort: a frozen class, or one whose columns is a getter without a setter, will not take the mirror and will not throw over it. Metadata is still available through the registry in that case.

Finding a Model by Table

getModelByTableName scans the registry — it is a linear search, so it suits startup work rather than a per-request lookup. Table names are not enforced unique, and the first registration wins if two models claim one.

by-table.ts
const model = MetadataStorage.getModelByTableName("users");
if (model) {
const repo = orm.getRepository(model as any);
await repo.find().execute(orm.client);
}