prorm.* — imperative methods on the connection.prorm.getQueryInterface() — the same operations, dialect-normalised,@View, @MaterializedView, @Trigger, @Procedure,@SqlFunction declared on a class and applied by sync().UnsupportedSchemaObjectError with an explanation rather than emitting SQL the[!TIP]
For objects defined as typed TypeScript values (views built withfrom(),sqlFunction/sqlProcedureroutines,defineTrigger, sequences, enums,
domains, policies and the describe decorators), all planned per engine
before anything runs, see The typed SQL-objects library.
import { UnsupportedSchemaObjectError } from 'ts-prorm-orm';
try {
await qi.createSequence('order_seq', { start: 1000 });
} catch (e) {
if (e instanceof UnsupportedSchemaObjectError) {
// "CREATE SEQUENCE is not supported on mysql. MySQL has no sequences -
// use an AUTO_INCREMENT column instead."
}
}findAll() and the dialect compiles the SELECT for you — there is no SQL toconst qi = prorm.getQueryInterface();
await qi.createView('customer_info', {
from: Customer,
attributes: ['firstName', 'lastName', 'email'],
where: { country: 'USA' },
});
await qi.showViews();
await qi.viewExists('customer_info');
await qi.dropView('customer_info');from isawait Customer.createView('customer_info', {
attributes: ['firstName', 'lastName', 'email'],
where: { country: 'USA' },
});qi.createView() call above; the model suppliesfrom. Omit the definition entirely for a view of the whole table, and passfrom anyway to select from somewhere else while keeping the call on the model:await Customer.createView('all_customers'); // SELECT *
await Customer.createMaterializedView('customer_totals', { attributes: ['id', 'total'] }); // same shape
await Customer.dropView('customer_info');await Customer.viewExists('customer_info'); // boolean
await Customer.refreshMaterializedView('customer_totals', { concurrently: true });
await Customer.materializedViewExists('customer_totals');
await Customer.dropMaterializedView('customer_totals');prorm.define(),prorm.addModel() or the decorators. A model that was never registered says sofrom takes a model class or a plain table name. Everything else — where,include, group, having, order, limit, distinct, cte, union —buildSelectQuery() that runs your findAll() calls. So theCREATE VIEW cannot carry bind parameters — the definition is stored in theO'Brien is safe:await qi.createView('irish_customers', { from: Customer, where: { name: "O'Brien" } });
// SELECT * FROM "customers" WHERE "name" = 'O''Brien'fn(), col() and cast() cover the[expression, alias] names the output column:import { fn, col } from 'ts-prorm-orm';
await qi.createView('customers_per_country', {
from: Customer,
attributes: ['country', [fn('COUNT', col('id')), 'customerCount']],
group: 'country',
});
// SELECT "country", COUNT("id") AS "customerCount" FROM "customers" GROUP BY "country"import { View } from 'ts-prorm-orm';
@View({
name: 'active_users',
definition: { from: 'users', attributes: ['id', 'name'], where: { status: 'active' } },
replace: true, // CREATE OR REPLACE VIEW
checkOption: 'LOCAL', // PostgreSQL
})
class ActiveUsers {}
prorm.addModel(ActiveUsers);
await prorm.sync(); // the view is created heredefinition needs a live dialect, so ViewRegistry.getSQL() wantsViewRegistry.createView(prorm, ActiveUsers)snake_case.Deprecated: passing a SELECT string to createView(), and the decorator'squeryandqueryFnoptions. Usedefinitioninstead. The string form still
works and is not scheduled for removal in 2.x, so existing code keeps running —
but it is no longer the documented way to define a view.
// Deprecated — still works, but prefer the object form above.
await qi.createView('active_users', "SELECT * FROM users WHERE status = 'active'");definition exists to take care of.definition:await prorm.createMaterializedView({
name: 'sales_by_month',
definition: {
from: Sale,
attributes: ['month', [fn('SUM', col('total')), 'total']],
group: 'month',
},
});
await prorm.refreshMaterializedView('sales_by_month', { concurrently: true });
await prorm.hasMaterializedView('sales_by_month');
await prorm.showMaterializedViews();
await prorm.dropMaterializedView('sales_by_month', { cascade: true });prorm.createMaterializedView() is PostgreSQL-only and throwsMaterialized views are only supported in PostgreSQL elsewhere. TheQueryInterface versions delegate straight to the dialect, so Oracle'sFAST / COMPLETE / FORCE refresh methods andON COMMIT / ON DEMAND schedules from RefreshOptions — are reachableprorm.getQueryInterface() or the dialect instance.@MaterializedView declares one on a class, with refreshStrategy defaulting'ON DEMAND'. It accepts definition exactly as @View does.Deprecated: queryandqueryFnon@MaterializedView, and thequery
option onprorm.createMaterializedView()/qi.createMaterializedView().
Passdefinitioninstead.
Model.refresh() andModel.isMaterializedView().import { ConstraintType, ReferentialAction } from 'ts-prorm-orm';
await Order.addConstraint('order_user_fk', {
type: ConstraintType.ForeignKey,
fields: ['userId'],
references: { table: 'users', fields: ['id'] },
onDelete: ReferentialAction.Cascade,
});{ type: 'UNIQUE' } and { type: ConstraintType.Unique } are the same value.prorm.getQueryInterface(), where the firsttableName option orawait qi.addColumn('Articles', 'slug', { type: DataTypes.STRING }); // by table
await Article.addColumn('slug', { type: DataTypes.STRING }); // by model| On the model | Delegates to |
|---|---|
Article.addColumn(name, def) | addColumn(table, …) |
Article.removeColumn(name) | removeColumn(table, …) |
Article.changeColumn(name, def) | changeColumn(table, …) |
Article.renameColumn(old, new) | renameColumn(table, …) |
Article.addIndex(fields, opts) | addIndex(table, …) |
Article.removeIndex(name) | removeIndex(table, …) |
Article.showIndexes() | showIndexes(table) |
Article.indexExists(name) | indexExists(table, …) |
Article.createFullTextIndex(opts) | createFullTextIndex({ table, … }) |
Article.addConstraint(name, def) | addConstraint(table, …) |
Article.removeConstraint(name) | removeConstraint(table, …) |
Article.getConstraints() | getConstraints(table) |
Article.getForeignKeys() | getForeignKeysForTable(table) |
Article.tableExists() | tableExists(table) |
Article.renameTable(newName) | renameTable(table, …) |
Article.describeTable() | describeTable(table) |
Article.enableRowLevelSecurity() | enableRowLevelSecurity(table) |
Article.disableRowLevelSecurity() | disableRowLevelSecurity(table) |
Article.createPolicy(opts) | createPolicy({ table, … }) |
Article.dropPolicy(name) | dropPolicy(name, table) |
Article.createTrigger(opts) | createTrigger({ table, … }) |
Article.dropTrigger(name) | dropTrigger(name, table) |
Article.createPartition(opts) | createPartition({ parentTable, … }) |
Article.attachPartition(opts) | attachPartition({ parentTable, … }) |
Article.detachPartition(opts) | detachPartition(…) |
Article.dropPartition(name) | dropPartition(name, …) |
Article.createView(name, def) | createView(name, { from: Article, … }) |
Article.createMaterializedView(name, def) | createMaterializedView({ definition, … }) |
Article.dropView(name) | dropView(name) |
Article.viewExists(name) | viewExists(name) |
Article.refreshMaterializedView(name, opts) | refreshMaterializedView(name, opts) |
Article.dropMaterializedView(name, opts) | dropMaterializedView(name, opts) |
Article.materializedViewExists(name) | materializedViewExists(name) |
table (or parentTable) get it filled in, andawait Article.createTrigger({ name: 'audit', timing: 'AFTER', event: 'INSERT', body: '…' });
await Article.createPolicy({ name: 'own_rows', using: { authorId: 42 } });
await Article.addIndex(['title'], { name: 'article_title_idx' });Note: Article.describeTable()reports the table as the database has it,
which is whatqi.describeTable()returns.Article.describe()is a
different, older method that returns the model's own attribute definitions.
whereawait qi.addConstraint('users', 'users_age_check', {
type: 'CHECK',
check: { age: { gte: 18 } },
});
// ALTER TABLE "users" ADD CONSTRAINT "users_age_check" CHECK ("age" >= 18)Note: a CHECKwith no predicate used to emitCHECK (1=1)— a constraint
that silently enforced nothing. It now throws instead.
await qi.enableRowLevelSecurity('documents');
await qi.createPolicy({
name: 'own_documents',
table: 'documents',
for: 'SELECT',
using: { ownerId: literalExpr('current_setting(\'app.user_id\')::int') },
});using decides which rows are visible; withCheck decides which rows may bewhere object or, still, a raw string.import { fn, col } from 'ts-prorm-orm';
await qi.addIndex('users', ['email'], {
name: 'users_email_lower_idx',
expression: fn('LOWER', col('email')), // LOWER("email")
});timing: 'BEFOR' used to reach the database before anything noticed:import { TriggerTiming, TriggerEvent, TriggerForEach, NEW, OLD, fn } from 'ts-prorm-orm';
await Article.createTrigger({
name: 'article_touch',
timing: TriggerTiming.Before,
event: TriggerEvent.Update,
forEach: TriggerForEach.Row,
body: { set: { updatedAt: fn('CURRENT_TIMESTAMP') } },
});table defaults to the model when the call sits on one, and otherwise takes abody takes actions rather than SQL. Each renders differently per engine, which| Action | Means |
|---|---|
{ set: { col: value } } | Assign to the row being written — SET NEW.col on MySQL, NEW.col := on PostgreSQL, :NEW.col := on Oracle |
{ insertInto: T, values: {…} } | Write a row elsewhere, usually an audit table |
{ update: T, set: {…}, where: {…} } | Update rows elsewhere |
{ deleteFrom: T, where: {…} } | Delete rows elsewhere |
{ raise: 'message' } | Reject the change — RAISE EXCEPTION, SIGNAL SQLSTATE, RAISE(ABORT), RAISE_APPLICATION_ERROR or THROW, per engine |
NEW('column') and OLD('column') referawait Article.createTrigger({
name: 'article_audit',
timing: TriggerTiming.After,
event: TriggerEvent.Update,
body: [
{ insertInto: AuditLog, values: { articleId: NEW('id'), was: OLD('title') } },
],
});Note: SQLite triggers may only run INSERT, UPDATE, DELETE or SELECT, so set
throws there with an explanation rather than emitting SQL the engine rejects.
SQL Server has noNEWrow at all and says so.
when is a where object. A bare key names a column of the incoming row,when: { status: { ne: OLD('status') } } // WHEN NEW."status" <> OLD."status"UPDATE trigger can also watch specific columns, on PostgreSQL and Oracle:await Article.createTrigger({
name: 'status_changed',
timing: TriggerTiming.After,
event: TriggerEvent.Update,
updateOf: ['status', 'title'], // UPDATE OF "status", "title"
body: '…',
});Note: the guard is compiled by a small dedicated builder, not by the one
behindfindAll(). That builder binds values, and a trigger'sWHENis
stored DDL that cannot carry parameters — comparing a column to another column
is the entire point of a guard. The supported operators are the comparisons,in/notIn,isNull/isNotNull, andand/or/not; anything
else throws, and a stringwhenis always available.
@Trigger takes the same vocabulary:@Trigger({
name: 'article_touch',
timing: TriggerTiming.Before,
event: TriggerEvent.Update,
body: { set: { updatedAt: fn('CURRENT_TIMESTAMP') } },
})
class Article extends Model {}@Procedure and @SqlFunction name their vocabularies too — ParamMode,Volatility and RoutineLanguage:class TextFunctions {
@SqlFunction({
name: 'slugify',
returns: 'text',
language: RoutineLanguage.PlPgSql,
volatility: Volatility.Immutable, // IMMUTABLE; Volatility.Deterministic on MySQL
params: [{ name: 'value', type: 'text', mode: ParamMode.In }],
body: '…',
})
slugify() {}
}Volatility names both families deliberately: PostgreSQL takesIMMUTABLE / STABLE / VOLATILE, MySQL takesDETERMINISTIC / NOT DETERMINISTIC, and they are not interchangeable.body stays a string, and that is not an oversight: a stored procedure iscreateExternalFunction() above.await qi.createExtension('uuid-ossp');
await qi.createExtension('postgis', { cascade: true });CREATE EXTENSION uuid-ossp is aINSTALL PLUGIN, aawait qi.createEnum('order_status', ['pending', 'shipped', 'delivered']);
await qi.addEnumValue('order_status', 'refunded', { after: 'delivered' });
await qi.renameEnumValue('order_status', 'shipped', 'dispatched');addEnumValue is the one that matters. An enum in production gains a memberALTER TYPE.await qi.createDomain('email', {
as: 'text',
notNull: true,
check: "VALUE ~ '@'", // VALUE stands for the value being checked
constraintName: 'email_shape',
});check is the one predicate in this guide still written as SQL, andVALUE rather than a column, sowhere object to name. A CHECK on a table is theaddConstraint('users', { type: ConstraintType.Check, check: { age: { gte: 0 } } }).ENUM is a column typeCHECK constraint, which addConstraint() builds.await Article.comment('Published posts, one row per revision');
await Article.commentOnColumn('slug', 'URL segment; unique per locale');
await Article.comment(null); // remove itNote: PostgreSQL stores comments as catalog entries, which is why it can set
one on a column independently. MySQL keeps a column comment inside the
column definition, so changing it means restating the column's full type and
constraints —commentOnColumn()says so and points atchangeColumn(),
which has them. A table comment works on both.
await qi.setSequenceValue('users_id_seq', 10_000);
await qi.restartSequence('users_id_seq', 1);
const next = await qi.nextSequenceValue('users_id_seq');setSequenceValue() is what a bulk load needs. Rows inserted with explicit idsNote: setvaltakes the sequence name as a string literal, not an
identifier, which is another detail hand-written calls get wrong. PassingisCalled: falsemakes the next value the one you gave, rather than one past
it.
ALTER TABLE … AUTO_INCREMENT = …; SQLite tracks the last rowid insqlite_sequence. Both are named in the error.await Article.analyze(); // refresh the planner's statistics
await Article.vacuum({ full: true });
await Article.reindex();
await Article.checkTable();| Call | PostgreSQL | MySQL | SQLite | SQL Server |
|---|---|---|---|---|
analyze() | ANALYZE | ANALYZE TABLE | ANALYZE | UPDATE STATISTICS |
vacuum() | VACUUM | — | VACUUM (whole db) | — |
reindex() | REINDEX | — | REINDEX | — |
optimizeTable() | — | OPTIMIZE TABLE | — | — |
checkTable() | — | CHECK TABLE | PRAGMA integrity_check | — |
repairTable() | — | REPAIR TABLE | — | — |
checksumTable() | — | CHECKSUM TABLE | — | — |
cluster() | CLUSTER | — | — | — |
vacuum() points you atoptimizeTable(), and asking PostgreSQL to optimizeTable() points back atvacuum({ full: true }).analyze() is the one worth running routinely. A planner working from stalevacuum() matters on engines that keep old row versions. That is why MySQLVACUUM{ full: true } rewrites the table and returns space to the operating system,await Article.vacuum({ full: true, analyze: true }); // VACUUM (FULL, ANALYZE)
await qi.vacuum(undefined, { into: '/backups/snap.db' }); // SQLite: a consistent copyVACUUM INTO is worth knowing: it writes a compacted copy and leaves thecluster() rewrites a table in one index's physical order. Worth it when aNote: checkTable()on SQLite checks the whole database, becausePRAGMA integrity_checkhas no per-table form. PostgreSQL has no built-in
check at all; the error names theamcheckextension rather than pretending.
createExternalFunction() registers either kind, and afterwards it is just aawait qi.createExternalFunction({
name: 'levenshtein',
language: 'c',
returns: 'INTEGER',
library: 'my_udf.so',
});
// CREATE FUNCTION `levenshtein` RETURNS INTEGER SONAME 'my_udf.so'library against the server's plugin_dir, so pass a bare fileSTRING, INTEGER, REAL orDECIMAL — anything else is rejected here rather than by the server — and the_init routine. Add aggregate: true for a GROUP BY-capableawait qi.createExternalFunction({
name: 'levenshtein',
language: 'c',
returns: 'integer',
params: [{ name: 'a', type: 'text' }, { name: 'b', type: 'text' }],
library: '$libdir/my_udf',
symbol: 'pg_levenshtein',
strict: true, // RETURNS NULL ON NULL INPUT
volatility: 'IMMUTABLE', // required to index a call to it
});await qi.createExternalFunction({
name: 'slugify',
language: 'plpython3u',
returns: 'text',
params: [{ name: 'value', type: 'text' }],
source: `
import re
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
`,
replace: true,
});await Article.findAll({ attributes: ['title', [fn('slugify', col('title')), 'slug']] });source is code in that language: it is stored verbatim and never parsed as| Language | language | Where the code lives |
|---|---|---|
| C / C++ / Rust (C ABI) | 'c' | compiled shared library |
| Python | 'plpython3u' | source |
| Perl | 'plperl', 'plperlu' | source |
| JavaScript | 'plv8' | source |
| R | 'plr' | source |
CREATE EXTENSION plpython3u), and MySQL supports only the compiled form —plpython3u throws a message saying so rather than emitting SQLdropExternalFunction(name, { params }); PostgreSQL identifiesparams when several exist.await qi.createSequence('order_seq', {
start: 1000, increment: 1, minValue: 1, maxValue: 99999,
cache: 20, cycle: true, ifNotExists: true,
});
await qi.dropSequence('order_seq', { ifExists: true });| Dialect | SQL |
|---|---|
| PostgreSQL | CREATE SEQUENCE IF NOT EXISTS "order_seq" START WITH 1000 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 CACHE 20 CYCLE |
| MariaDB | identical (MariaDB 10.3+) |
| Oracle | same without IF NOT EXISTS — Oracle has no such clause |
| SQL Server | same without CACHE |
| MySQL | throws — "MySQL has no sequences - use an AUTO_INCREMENT column instead." |
| SQLite | throws — "SQLite has no sequences - use an INTEGER PRIMARY KEY AUTOINCREMENT column instead." |
DROP SEQUENCE omits IF EXISTS on Oracle for the same reason.git blame that pointsawait qi.createExternalFunction({
name: 'slugify',
language: 'plpython3u',
returns: 'text',
params: [{ name: 'value', type: 'text' }],
file: './db/functions/slugify.py',
});slugify.py stays a Python file that Python tooling understands.file takes a path, a file: URL, a Buffer, or a browser File/Blob — thesource and file is an error rather than a silent preference,@FromFile reads better when the rest of theclass Reports {
@Procedure({ name: 'rebuild_totals' })
@FromFile('./db/procedures/rebuild_totals.sql')
rebuildTotals!: () => Promise<void>;
}Note: decorators apply bottom-up, so @FromFilemust sit below the
routine decorator that consumes it. It builds its DDL synchronously, so aFile/Blob— readable only asynchronously — is rejected with that reason;
pass a path there.
await qi.createTrigger({
name: 'stamp_updated',
table: 'users',
timing: 'BEFORE',
event: 'UPDATE',
forEach: 'ROW',
when: 'OLD.email <> NEW.email',
body: 'NEW.updated_at = NOW();',
});
await qi.dropTrigger('stamp_updated', 'users');WHEN, SQLite has no FOR EACH, and SQL Server has no BEFOREUnsupportedSchemaObjectError: a BEFORE trigger is not supported on mssql.
SQL Server has only AFTER and INSTEAD OF triggers. Use AFTER, or INSTEAD OF
with a body that performs the write itself.[!WARNING]
On MySQL thewhencondition is dropped, so the body runs on every update.
Put the condition inside the body (IF OLD.email <> NEW.email THEN … END IF;)
or use the typeddefineTrigger, which moves awhenguard into the body for you.
sync():@Trigger({
name: 'stamp_updated',
timing: 'BEFORE',
event: 'UPDATE',
forEach: 'ROW',
body: 'NEW.updated_at = NOW();',
})
@Table({ tableName: 'users' })
class User extends Model {}prorm.createTrigger() / prorm.dropTrigger() are the connection-levelimport { Procedure, SqlFunction } from 'ts-prorm-orm';
class Reports {
@Procedure({
name: 'archive_orders',
params: [{ name: 'cutoff', type: 'DATE', mode: 'IN' }],
body: 'DELETE FROM orders WHERE created_at < cutoff;',
dialect: 'postgres',
replace: true,
})
archiveOrders() {}
@SqlFunction({ name: 'order_total', returns: 'DECIMAL(10,2)', body: '…' })
orderTotal() {}
}
prorm.registerProcedures(Reports);
await prorm.sync(); // procedures and functions are created hereconst dialect = prorm.getDialectInstance();
await dialect.executeStoredProcedure({ name: 'archive_orders', params: [cutoff] });
await dialect.hasStoredProcedure('archive_orders');
await dialect.dropStoredProcedure('archive_orders', { ifExists: true });prorm.createAlgorithm() / dropAlgorithm() cover the same ground with aawait qi.enableRowLevelSecurity('docs');
await qi.createPolicy({
table: 'docs',
name: 'tenant_isolation',
for: 'ALL',
to: ['app_user'],
using: { tenant_id: literalExpr("current_setting('app.tenant')::int") },
withCheck: { tenant_id: literalExpr("current_setting('app.tenant')::int") },
});
await qi.dropPolicy('tenant_isolation', 'docs');
await qi.disableRowLevelSecurity('docs');CREATE SECURITY POLICY with a predicate function instead. Oracle's VPDRowLevelSecurity and SessionIsolation modules inawait prorm.createPartitionedTable('events', columns, {
partitionBy: { type: 'range', column: 'created_at' },
partitions: [
{ name: 'events_2026_q1', bound: { from: '2026-01-01', to: '2026-04-01' } },
],
});
await prorm.createPartition({
parentTable: 'events',
name: 'events_2026_q2', // note: `name` here…
bound: { from: '2026-04-01', to: '2026-07-01' },
});
await prorm.attachPartition({ parentTable: 'events', partitionName: 'events_2026_q2' });
await prorm.detachPartition({ partitionName: 'events_2026_q2', validate: true });
await prorm.dropPartition('events_2026_q2');createPartition takes name; attachPartition / detachPartition takepartitionName — the asymmetry is real, not a typo here.)bound covers all three schemes: { from, to } for RANGE, { values } forLIST, { modulus, remainder } for HASH.RANGE, LIST and HASH are supported, with the syntax each engine uses —PARTITION BY clauses on MySQL/MariaDB,QueryInterface.await prorm.createSchema('reporting');
await prorm.showSchemas();
await prorm.dropSchema('reporting', { cascade: true });
await prorm.createDatabase({ name: 'analytics', encoding: 'UTF8', template: 'template0' });
await prorm.dropDatabase('analytics');schema on a model (or searchPath on a query) to target one.await prorm.createExtension('postgis', { ifNotExists: true, schema: 'public' });
await prorm.hasExtension('postgis');
await prorm.getExtensions();
await prorm.dropExtension('postgis', { cascade: true });findAll-style options and hands the resultts-prorm-orm/sql, also reachable as theSql namespace of the root package) goes further: views, materialized views,sync() creates, replaces or| Object | Define with | Register with |
|---|---|---|
| View | @View(query) on a ReadonlyModel, or Model.asView(name, query) | the model itself |
| Materialized view | @MaterializedView(query, { refresh }) | the model itself |
| Function | sqlFunction(spec, body) as a static of a class | prorm.use(Group) |
| Procedure | sqlProcedure(spec, body) as a static of a class | prorm.use(Group) |
| Trigger | defineTrigger(Model, spec, body) or @Trigger(spec, body) | prorm.sqlObjects.addObject(trigger.def) |
| Sequence | defineSequence({ name, start, … }), or @Sequence on a column | prorm.use(objectsPlugin(…)) |
| Enum type | defineEnum(name, values) | objectsPlugin(…) |
| Domain | defineDomain(name, baseType, check) | objectsPlugin(…) |
| Row-level security policy | definePolicy(Model, { using, withCheck }) | objectsPlugin(…) |
| Comments, checks, guards | @Description, @Between, @Pattern, @Immutable … | prorm.use(describePlugin()) |
[!NOTE]
The examples importSqlfromts-prorm-orm. In an application you can
import the same names directly:import { from, fn, t, sqlFunction } from 'ts-prorm-orm/sql'.
import { Model, Table, Column, PrimaryKey, AutoIncrement, DataTypes } from 'ts-prorm-orm';
@Table({ tableName: 'users', timestamps: false })
export class User extends Model {
@PrimaryKey @AutoIncrement @Column(DataTypes.INTEGER()) declare id: number;
@Column(DataTypes.STRING()) declare email: string;
@Column(DataTypes.STRING()) declare status: string;
@Column(DataTypes.INTEGER()) declare tenantId: number;
}
@Table({ tableName: 'orders', timestamps: false })
export class Order extends Model {
@PrimaryKey @AutoIncrement @Column(DataTypes.INTEGER()) declare id: number;
@Column(DataTypes.INTEGER()) declare userId: number;
@Column(DataTypes.STRING()) declare status: string;
@Column(DataTypes.DECIMAL(12, 2)) declare total: number;
@Column(DataTypes.DATE()) declare updatedAt: Date;
}
@Table({ tableName: 'order_lines', timestamps: false })
export class OrderLine extends Model {
@PrimaryKey @AutoIncrement @Column(DataTypes.INTEGER()) declare id: number;
@Column(DataTypes.INTEGER()) declare orderId: number;
@Column(DataTypes.INTEGER()) declare quantity: number;
@Column(DataTypes.DECIMAL(12, 2)) declare unitPrice: number;
}
@Table({ tableName: 'order_events', timestamps: false })
export class OrderEvent extends Model {
@PrimaryKey @AutoIncrement @Column(DataTypes.INTEGER()) declare id: number;
@Column(DataTypes.INTEGER()) declare orderId: number;
@Column(DataTypes.STRING()) declare op: string;
@Column(DataTypes.JSON()) declare payload: unknown;
}from(Model, alias) gives a scope whose columns are typed from the model, so aimport { Sql } from 'ts-prorm-orm';
const { from } = Sql;
const bigOrders = from(Order, 'o')
.join(User, 'u', ({ o, u }) => o.userId.eq(u.id))
.where(({ o }) => o.total.gte(100))
.orderBy(({ o }) => o.total.desc())
.limit(10)
.select(({ o, u }) => ({ orderId: o.id, email: u.email, total: o.total }));
const rows = await prorm.select(bigOrders); // or: await bigOrders.all(prorm)
console.log(bigOrders.toSQL('postgres'));toSQL(dialect) renders the statement for reading, with values inlined;all() and prorm.select() bind them as parameters.import { ReadonlyModel, View, Sql } from 'ts-prorm-orm';
const { fn } = Sql;
@View(
(q) =>
q
.from(User, 'u')
.leftJoin(Order, 'o', ({ u, o }) => o.userId.eq(u.id))
.where(({ u }) => u.status.eq('active'))
.groupBy(({ u }) => [u.id, u.email])
.select(({ u, o }) => ({
userId: u.id,
email: u.email,
orderCount: fn.count(o.id),
lifetimeValue: fn.coalesce(fn.sum(o.total), 0),
})),
{ name: 'customer_summary', description: 'One row per active customer.' },
)
export class CustomerSummary extends ReadonlyModel<CustomerSummary> {
declare userId: number;
declare email: string;
declare orderCount: number;
declare lifetimeValue: number;
}sync() creates it with each engine's replace form. Reads work like any modelCustomerSummary.findAll({ where: { email } })); writes are refused.const ActiveUser = User.asView('active_users', (q) => q.where(({ u }) => u.status.eq('active')));@MaterializedView takes the same query plus how the stored rows are keptimport { ReadonlyModel, MaterializedView, Sql } from 'ts-prorm-orm';
const { fn, Refresh } = Sql;
@MaterializedView(
(q) =>
q
.from(Order, 'o')
.where(({ o }) => o.status.eq('paid'))
.groupBy(({ o }) => [o.userId])
.select(({ o }) => ({ userId: o.userId, revenue: fn.sum(o.total) })),
{
name: 'user_revenue',
refresh: Refresh.every('15m'),
indexes: [{ unique: true, fields: ['userId'] }],
},
)
export class UserRevenue extends ReadonlyModel<UserRevenue> {
declare userId: number;
declare revenue: number;
}| Engine | What sync() builds |
|---|---|
| PostgreSQL | CREATE MATERIALIZED VIEW … WITH DATA plus the unique index |
| MySQL | a table filled by CREATE TABLE … AS SELECT, a prorm_refresh_user_revenue procedure and, for Refresh.every(), an EVENT that calls it |
| SQLite | a table filled by CREATE TABLE … AS SELECT, refreshed by the app |
| SQL Server | an indexed view (WITH SCHEMABINDING plus a unique clustered index) |
Refresh.onDemand() (the default), Refresh.onCommit(), Refresh.incremental()Refresh.every('30s' | '15m' | '1h' | '1d' | '1w') pick the refresh mode.[!WARNING]
Emulations are not free of differences, and the plan reports each one. A MySQLEVENTonly runs withevent_scheduler=ON(aMATERIALIZED_VIEW_REFRESH
warning). A SQL Server indexed view wrapsSUMarguments inISNULL(…, 0), so
a group whose values are allNULLsums to0rather thanNULL(aSEMANTIC_DIVERGENCEnote).
$.declare, $.if,$.update, $.insert, $.return, $.raise, $.rowCount() and so on. It isprorm.use(Group) registers it:import { Sql } from 'ts-prorm-orm';
const { sqlFunction, sqlProcedure, out, t } = Sql;
export class Pricing {
/** An expression function: the body is one expression. */
static lineTotal = sqlFunction(
{
name: 'line_total',
params: { quantity: t.int(), unitPrice: t.decimal(12, 2) },
returns: t.decimal(12, 2),
deterministic: true,
},
({ quantity, unitPrice }) => unitPrice.mul(quantity),
);
/** A function with statements: a variable and two branches. */
static shippingFee = sqlFunction(
{
name: 'shipping_fee',
params: { subtotal: t.decimal(12, 2), express: t.boolean() },
returns: t.decimal(12, 2),
deterministic: true,
},
({ subtotal, express }, $) => {
const fee = $.declare('fee', t.decimal(12, 2), 7.5);
return [
$.if(subtotal.gte(100)).then(fee.set(0)),
$.if(express).then(fee.set(fee.add(15))),
$.return(fee),
];
},
);
/** A procedure with an OUT parameter. */
static cancelStaleOrders = sqlProcedure(
{ name: 'cancel_stale_orders', params: { before: t.timestamp(), cancelled: out(t.int()) } },
({ before, cancelled }, $) => [
$.update(Order, 'o')
.set(() => ({ status: 'cancelled' }))
.where(({ o }) => o.status.eq('pending').and(o.updatedAt.lt(before))),
cancelled.set($.rowCount()),
],
);
}prorm.use(Pricing);
await prorm.sync(); // creates line_total, shipping_fee and cancel_stale_orders
// Typed calls: argument names and types come from `params`.
const fee = await Pricing.shippingFee({ subtotal: 120, express: true }); // '15.00'
const { cancelled } = await Pricing.cancelStaleOrders({ before: new Date('2026-09-01') });
// Or bound to a specific instance:
const pricing = prorm.routines(Pricing);
await pricing.lineTotal({ quantity: 3, unitPrice: 19.99 });'15.00'), as they do from the drivers, so noerror OBJECT_UNSUPPORTED: sqlite has no stored functions beyond single-expression functions
hint: Run it in the app (fallback: "app" on the routine, or
new Prorm({ sqlObjects: { fallback: { sqlite: 'app' } } })), or skip it on this engine.error OBJECT_UNSUPPORTED: sqlite has no stored procedures[!IMPORTANT]
With binary logging on (the MySQL 8 default),CREATE FUNCTIONneedsSUPER
orlog_bin_trust_function_creators = 1, or it fails with error 1419. The plan
flags this asROUTINE_BINLOG_TRUST; Prorm never changes server variables, so
set it on the server (the parameter group on a managed service).
[!WARNING]
On MySQL,$.rowCount()after anUPDATEcounts matched rows only for
clients that connect withCLIENT_FOUND_ROWS.mysql2, the ORM's driver, does;
other clients calling the same procedure get changed rows. The plan warns withSEMANTIC_DIVERGENCE.
defineTrigger(Model, spec, body) takes the table, the timing and events, anwhen guard, and a body in the same statement DSL. The body receivesnew and old rows: old does not exist in an insert-only trigger, andn.set() is available only where the timing can be BEFORE, so those mistakesimport { Sql } from 'ts-prorm-orm';
const { defineTrigger, fn } = Sql;
/** Stamp updatedAt whenever the status changes. */
export const touchOrder = defineTrigger(
Order,
{
name: 'orders_touch',
timing: 'before',
events: ['update'],
when: ({ new: n, old: o }) => n.status.ne(o.status),
},
({ new: n }) => [n.set({ updatedAt: fn.now() })],
);
/** Append every insert and update to order_events as JSON. */
export const auditOrder = defineTrigger(
Order,
{ name: 'orders_audit', timing: 'after', events: ['insert', 'update'] },
({ new: n }, $) => [
$.insert(OrderEvent).values({ op: $.op(), orderId: n.id, payload: $.rowToJson('new') }),
],
);
/** Reject negative totals with a coded error. */
export const guardOrder = defineTrigger(
Order,
{ name: 'orders_guard', timing: 'before', events: ['insert', 'update'] },
({ new: n }, $) => [
$.assert(n.total.gte(0), 'total must not be negative', { code: 'TOTAL_NEGATIVE' }),
],
);
for (const trigger of [touchOrder, auditOrder, guardOrder]) {
prorm.sqlObjects.addObject(trigger.def);
}inserted/deleted:$.assert raises an engine-native error that carries the code, so the app canTOTAL_NEGATIVE apart from any other failure:| Trigger | Refused on | Diagnostic and hint |
|---|---|---|
n.set() in a BEFORE trigger (orders_touch) | SQLite | TRIGGER_SET_ROW: SQLite trigger bodies cannot assign to NEW; use an AFTER trigger that updates the row by its key, or a column default |
any BEFORE trigger | SQL Server | TRIGGER_TIMING: no BEFORE triggers; use timing: 'after', or opt in with mssqlBefore: 'after' or mssqlBefore: 'insteadOf' |
| several events | MySQL, SQLite | not refused: split into _ins / _upd / _del triggers (TRIGGER_SPLIT, info) |
[!TIP]
The decorator form keeps a trigger next to its model:@Trigger<typeof Order>({ timing: 'before', events: ['insert'] }, ({ new: n }) => [n.set({ status: 'pending' })]).
Without aname, a trigger is called<table>_<timing>_<events>, for exampleorders_after_ins_del.
objectsPlugin(). applyTo(Model, attribute) binds an enum or a domain to aCHECK onimport { Sql } from 'ts-prorm-orm';
const { defineSequence, defineEnum, defineDomain, definePolicy, sessionSetting, objectsPlugin, t } = Sql;
export const OrderNo = defineSequence({ name: 'order_no', start: 1000, cache: 20 });
export const OrderStatus = defineEnum('order_status', ['pending', 'paid', 'shipped', 'cancelled'] as const);
export const Email = defineDomain('email', t.varchar(255), (v) => v.like('%@%'), { notNull: true });
export const TenantOnly = definePolicy(User, {
name: 'tenant_isolation',
using: (u) => u.tenantId.eq(sessionSetting('app.tenant_id', t.int())),
});
prorm.use(
objectsPlugin(
OrderNo,
OrderStatus,
OrderStatus.applyTo(Order, 'status'),
Email,
Email.applyTo(User, 'email'),
TenantOnly,
),
);
// A sequence is also a typed expression and a runtime call:
const next = await OrderNo.next(prorm); // SELECT nextval('order_no') on PostgreSQL
OrderStatus.is('paid'); // true — a type guard for the values| Object | PostgreSQL | MySQL | SQLite | SQL Server |
|---|---|---|---|---|
defineSequence | CREATE SEQUENCE | refused | refused | CREATE SEQUENCE … AS BIGINT |
defineEnum + applyTo | CREATE TYPE … AS ENUM | inline ENUM(…) column type | CHECK triggers | CHECK constraint |
defineDomain + applyTo | CREATE DOMAIN | CHECK constraint | CHECK triggers | CHECK constraint |
definePolicy | CREATE POLICY + ENABLE ROW LEVEL SECURITY | refused | refused | refused |
sync(). A changed sequence gets ALTER SEQUENCE without RESTART, so theallowRebuild: true.[!CAUTION]
This policy reads the tenant fromcurrent_setting('app.tenant_id', true).
When the setting is missing, it comparestenantIdwithNULLand returns no
rows, which fails safe. It still only protects the queries that run as a role
the policy covers: table owners bypass row-level security unless you passforce: true.
import { Prorm, Sql } from 'ts-prorm-orm';
const { sqlFunction, t } = Sql;
class Pricing {
static shippingFee = sqlFunction(
{
name: 'shipping_fee',
params: { subtotal: t.decimal(12, 2), express: t.boolean() },
returns: t.decimal(12, 2),
deterministic: true,
fallback: 'app', // this routine only
},
({ subtotal, express }, $) => {
const fee = $.declare('fee', t.decimal(12, 2), 7.5);
return [
$.if(subtotal.gte(100)).then(fee.set(0)),
$.if(express).then(fee.set(fee.add(15))),
$.return(fee),
];
},
);
}
// …or every routine on an engine:
const prorm = new Prorm({
dialect: 'sqlite',
storage: ':memory:',
sqlObjects: { fallback: { sqlite: 'app' } },
});
await prorm.connect();
prorm.use(Pricing);
await prorm.sync();
await Pricing.shippingFee({ subtotal: 40, express: false }); // '7.50'
await Pricing.shippingFee({ subtotal: 120, express: true }); // '15.00'[!WARNING]
A trigger that falls back to ORM hooks fires only for writes made through
models. Rawprorm.query()statements and other clients bypass it, and the
plan warns you about this.
describePlugin(), the database itself:import {
Model, Table, Column, PrimaryKey, AutoIncrement, DataTypes,
Description, Unit, Between, Immutable, Sensitive, Deprecated,
describeModel, toJsonSchema, redactSensitive, Sql,
} from 'ts-prorm-orm';
@Description('A customer invoice.')
@Table({ tableName: 'invoices', timestamps: false })
export class Invoice extends Model {
@PrimaryKey @AutoIncrement @Column(DataTypes.INTEGER()) declare id: number;
@Description('Invoice total in cents.')
@Unit('cents')
@Between(0, 10_000_000)
@Column(DataTypes.INTEGER())
declare totalCents: number;
@Immutable @Column(DataTypes.STRING()) declare reference: string;
@Sensitive({ category: 'PII', mask: 'email' })
@Column(DataTypes.STRING())
declare customerEmail: string;
@Deprecated({ since: '4.2', use: 'totalCents' })
@Column(DataTypes.DECIMAL(12, 2))
declare total: number;
}
prorm.use(Sql.describePlugin()); // comments, CHECKs and guard triggers in sync()
describeModel(Invoice).attributes[1].comment; // 'Invoice total in cents. [unit: cents]'
toJsonSchema(Invoice); // totalCents: { minimum: 0, maximum: 10000000, 'x-unit': 'cents' }
redactSensitive(Invoice, { id: 1, customerEmail: 'ada@example.com' });
// → { id: 1, customerEmail: 'a***a@example.com' }| Decorator | In the app | In the database (describePlugin()) |
|---|---|---|
@Description(text) | docs, JSON Schema description, codegen JSDoc | COMMENT ON (PostgreSQL), table COMMENT (MySQL), MS_Description (SQL Server) |
@Unit(unit) | x-unit; unitsCompatible() | [unit: cents] in the comment |
@Between(min, max) | a range validator; JSON Schema minimum/maximum | CHECK (col BETWEEN min AND max) |
@Pattern(regex) | a matches validator; JSON Schema pattern | a regex CHECK where the engine has one |
@Immutable | updates refused with a ValidationError | a BEFORE UPDATE OF col trigger |
@Sensitive({ category, mask }) | redactSensitive(), compliance classification | [sensitive: PII] in the comment |
@Deprecated({ since, use }) | a one-time DeprecationWarning on use | [deprecated since …; use …] in the comment |
@Example(value) | checked against validators outside production | none |
[!NOTE] @ImmutableneedsUPDATE OF columntriggers for its database guard, which
MySQL lacks; the plan reportsTRIGGER_EVENTthere. The application check
(ValidationError: Invoice.reference is immutable and cannot be updated)
applies on every engine.
const plan = prorm.sqlObjects.plan('postgres');
for (const sql of plan.statements()) console.log(`${sql};`);
for (const d of plan.diagnostics) console.log(d.severity, d.code, d.object, d.message);const problems = prorm.sqlObjects
.check(['postgres', 'mysql', 'mssql'])
.filter((d) => d.severity === 'error');
if (problems.length) throw new Error(problems.map((d) => `${d.code} ${d.object}`).join('\n'));await prorm.sync({ objects: { dryRun: true } }); // report only
await prorm.sync({ objects: { prune: true } }); // apply; drop objects no longer definedsync() records each object's hash in a prorm_objects table. An unchangedprune drops only objects that Prorm created. The same steps are availableprorm objects plan --dialect mysql,prorm objects check --dialect all and prorm objects apply --dry-run (see@View, @Trigger, @Procedureprorm objects plan, check and apply@Sensitive feeds