1. guides
prorm
  • prorm documentation
  • Known test gaps
  • Architecture
  • Core concepts
  • Database types & coverage
  • Getting started
  • Going further
  • compliance
    • Compliance
    • The security toolkit: `prorm audit`
    • Checklists by regulation
    • Declaring personal data
    • Evidence bundles
    • Evidence
    • Frameworks: `@Security`
    • Jurisdictions
    • Platform policies
    • Protecting data
    • The regulation dataset
    • Residency and cross-border transfers
    • Retention & integrity
    • Status
    • Subject rights
  • decorators
    • prorm with decorators
    • Models with decorators
    • Querying a decorated model
    • Relations with decorators
  • diagrams
    • Diagram generators
    • Chen diagram
    • Class diagram
    • Dependency diagram
    • ER diagram
    • Flow diagram
    • From your models to a diagram
    • Gantt diagram
    • Index diagram
    • Migration diagram
    • Model diagram
    • Package diagram
    • Crow’s-foot diagram
    • Schema documentation
    • Sequence diagram
    • State diagram
    • Tree diagram
  • dialects
    • SQL dialects
    • AlloyDB Dialect
    • AlloyDB Omni Dialect
    • Amazon Athena Dialect
    • Aurora DSQL Dialect
    • Amazon Aurora MySQL Dialect
    • Amazon Aurora PostgreSQL Dialect
    • Azure Database for MySQL Dialect
    • Azure Database for PostgreSQL Dialect
    • Azure SQL Database Dialect
    • Azure SQL Managed Instance Dialect
    • Google BigQuery Dialect
    • Citus Dialect
    • ClickHouse dialect
    • Cloud Authentication (`cloud-auth`)
    • Cloud SQL for SQL Server Dialect
    • Cloud SQL for MySQL Dialect
    • Cloud SQL for PostgreSQL Dialect
    • CockroachDB Dialect
    • Azure Cosmos DB for PostgreSQL Dialect
    • CrateDB Dialect
    • Cloudflare D1 Dialect
    • Databricks dialect
    • IBM Db2 Dialect
    • Dolt Dialect
    • Apache Doris Dialect
    • Druid Dialect
    • DuckDB Dialect
    • Exasol dialect
    • Microsoft Fabric Data Warehouse Dialect
    • Firebird Dialect
    • Greenplum Dialect
    • SAP HANA Dialect
    • MySQL HeatWave Dialect
    • Azure Data Explorer (Kusto) Dialect — T-SQL
    • MariaDB Dialect
    • Materialize Dialect
    • MonetDB Dialect
    • MotherDuck Dialect
    • Microsoft SQL Server Dialect
    • MySQL Dialect
    • Neon Dialect
    • OceanBase Dialect
    • openGauss Dialect
    • Oracle Dialect
    • Oracle Autonomous Database Dialect
    • ParadeDB Dialect
    • PolarDB Dialect
    • PostgreSQL Dialect
    • Presto Dialect
    • QuestDB Dialect
    • Aurora RDS Data API Dialect (`rdsdata`)
    • Amazon RDS for Db2 Dialect
    • Amazon RDS for MariaDB Dialect
    • Amazon RDS for SQL Server Dialect
    • Amazon RDS for MySQL Dialect
    • Amazon RDS for Oracle Dialect
    • Amazon RDS for PostgreSQL Dialect
    • Amazon Redshift
    • Amazon Redshift Data API Dialect
    • RisingWave Dialect
    • rqlite Dialect
    • SingleStore Dialect
    • Snowflake Dialect
    • Google Cloud Spanner Dialect
    • Spanner (PostgreSQL interface) Dialect
    • SQLite Dialect
    • StarRocks Dialect
    • Supabase Dialect
    • Azure Synapse Analytics (dedicated SQL pool)
    • Teradata Dialect
    • TiDB Dialect
    • TimescaleDB Dialect
    • Trino Dialect
    • Turso / libSQL Dialect
    • Vertica dialect
    • Vitess Dialect
    • Yellowbrick Dialect
    • YugabyteDB Dialect
  • guides
    • Access rules and behaviour profiles
    • ArangoDB (AQL)
    • Associations
    • Audit levels and escalation
    • Audit logging
    • In the browser
    • Bulk operations
    • Caching
    • Capabilities, tiers and team roles
    • CLI
    • Cloudflare
    • Compliance & data protection
    • Connection Pooling & Performance
    • Security in the console
    • Couchbase: structured N1QL queries
    • Data types
    • Decorators
    • Eager loading
    • Enums and constants
    • Error handling & error types
    • Extensions
    • External fields
    • Foreign data wrappers
    • Graph databases
    • Hooks & lifecycle events
    • Indexes & constraints
    • InfluxDB: structured Flux pipelines
    • klock-errors
    • klock — the `.klock` file
    • Local databases (`prorm db up`)
    • Structured log search — Splunk, Graylog, Papertrail, Sumo Logic
    • Logging
    • Grafana Loki: structured LogQL queries
    • MCP server
    • Migrate — the data transfer subsystem
    • Migrations & Schema Sync
    • Models from an existing database
    • Defining models
    • Multiple databases
    • OrientDB, Neptune and TigerGraph
    • Passwords
    • Importing a Prisma schema
    • Structured PromQL and MetricsQL
    • Query firewall
    • QueryInterface
    • Query operators reference
    • Query optimization
    • Composing find options with `pipe()` and `Q`
    • Query safety: the fail-closed WHERE guard
    • Querying
    • Raw Queries & Query Building
    • Read replicas
    • Schema diffing & migration generation
    • Views, triggers & other schema objects
    • Scopes & default scopes
    • Seeders
    • SPARQL / RDF triplestores
    • SQL constants
    • SQL features
    • SQL function builders
    • SQLite advanced features
    • StoreKey — a prod-orm database as the storage
    • Streaming results
    • Terraform
    • Transactions & Isolation Levels
    • Type mapping: legacy and modern
    • TypeScript types
    • Database users, roles & privileges
    • Validation & constraints
    • Virtual & Computed Fields
    • Writing docs
  • javascript
    • prorm in JavaScript
    • Models in JavaScript
    • Querying in JavaScript
    • Relations in JavaScript
  • runbooks
    • Operational runbooks
    • Connection failures
    • Credential rotation
    • Failover
    • Incident checklist
    • Deploying klock v2
    • Migration rollback
  • running-databases
    • Running databases in Docker
    • Running prorm under Bun in Docker
    • Running Cassandra in Docker with prorm
    • Running ClickHouse in Docker with prorm
    • Running CockroachDB in Docker with prorm
    • Running prorm under Deno in Docker
    • Running Elasticsearch in Docker with prorm
    • Running MariaDB in Docker with prorm
    • Running MongoDB in Docker with prorm
  • prorm control plane
    • Introduction
    • Authentication
    • Errors
    • Getting started
      • Service
        • Is it up
        • This description
    • Account
      • Sign-up and verification
        • Create an account
        • Exchange credentials for a token
        • Verify with the emailed code
        • Verify by clicking the emailed link
        • Name the parent or guardian
        • What a consent link is about
        • Give consent for a child's account
        • Ask the parent or guardian again
        • Send a new code and link
      • Family
        • Everyone this account is responsible for
        • One child, in detail
        • Turn a limit on or off
      • Profile
        • Who am I
        • Change the account
        • Every address on the account
        • Add an address
        • Confirm an address with the code mailed to it
        • Mail a fresh code to an unconfirmed address
        • Make an address primary
        • Remove an address
        • What this caller may do
    • Security
      • Sessions
        • Everywhere this account is signed in
        • Sign out everywhere else
        • Sign one out
        • A token for the CLI on a named machine
        • A token for the customer console, from the staff one
        • Park a sign-in for the CLI to collect
        • The CLI collecting its sign-in
        • The team's members' live sessions
        • Sign a member's session out
        • Sign an account out everywhere
        • Everywhere the account is signed in
        • Sign an account's session out
      • Single sign-on
        • Begin a single sign-on
        • The provider's return
        • Link the signed-in account to a team's provider
        • Which team an address signs in to
        • The team's single sign-on, as a member sees it
        • Set the team's OpenID Connect provider up, or change it
        • Remove the provider
        • The team's domain claims and the DNS record each needs
        • Prove a claimed domain
      • MFA and passwords
        • Start enrolling a second factor
        • Prove the app has it
        • Remove the second factor
        • Ask for a reset link
        • Set a new password with the emailed token
      • Klock
        • Open a device-bound klock session
        • Check in, for the next grant
        • Step up the session asking
        • Rotate the device key
        • Freeze a session
        • Unfreeze a session
        • A guest grant
        • The revocation feed
        • The published keyset
        • What this control plane supports
      • Devices
        • Your devices, or a team's
        • Approve a new device's pairing code
        • One device
        • Rename a device
        • Revoke a device
        • Suspend a device
        • Reinstate a suspended device
        • Wipe a device
        • Ask a device to rotate its key
        • The team's members' devices
        • Revoke a member's device
        • An account's devices
        • Revoke an account's device
        • Wipe an account's device
        • The device fleet
      • Access rules and behaviour
        • The access rules that bear on you
        • Add an access rule
        • This request, as the rules see it
        • One access rule
        • Remove an access rule
        • The behaviour profiles that bear on you
        • Add or change a behaviour profile
        • The profile presets
        • Your behaviour findings
        • Acknowledge a finding
        • One behaviour profile
        • Remove a behaviour profile
        • The team's access rules
        • Add a team access rule
        • Remove a team access rule
        • The team's behaviour profiles
        • Add or change a team behaviour profile
        • Remove a team behaviour profile
        • What the team's profiles caught
        • Acknowledge a team finding
      • Recovery and lockdown
        • Ask for a recovery link
        • Recover access with the emailed code
        • Is the account locked down
        • Lock the account down
        • Release a lockdown
        • Lock a member down
        • Release a member's lockdown
      • MCP tokens
        • Make a delegated token for one MCP client
        • The account's delegated tokens
        • Revoke a delegated token
        • What a delegated token may do now
        • Run one MCP tool with a delegated token
        • Run a device-signed MCP tool call relayed by the MCP Worker
      • Kerberos
        • Give the team its own Kerberos realm
        • The team's realm
        • Remove the team's realm
    • Logging and monitoring
      • Audit log
        • What has been done to the account
        • What has happened to this account
        • The account's audit trail
      • Operations
        • Everything that has been asked of this database
        • One, with its steps
        • Stop it
        • Put a failed one back in the queue
        • Every operation, across all accounts
      • Metrics and events
        • Every measurement, for a graph
        • Who did what to it
    • Billing
      • Plans and pricing
        • What the plans allow
        • The rate card
        • What one database would cost
        • What this account is paying for, itemised
      • Subscriptions and invoices
        • What this month's invoice will say
        • What this account is on
        • What it has been charged
        • Start a Stripe Checkout session
        • Open the customer portal
        • Stripe's events
        • Buy more connection pools
      • Team billing
        • The team's billing account
        • What the team has been charged
        • What this month's invoice will say
        • The team's cards
        • Add a card, through a Stripe-hosted setup session
        • Make a card the team's default
        • Remove a card
        • Start the team paying
        • Open the Stripe portal on the team's customer
        • Change the team's plan
        • Who receives the team's bills
        • How the team pays for transfers
    • Support
      • Support chat
        • My conversations
        • Start a conversation
        • Every support conversation, open first
        • A ticket for the socket
        • One conversation, with who is in it and who is connected
        • Rename a group; open or close a support conversation
        • The lines, oldest first
        • Say something
        • Add somebody
        • Mark read
    • Administration
      • Users
        • Every account
        • One account, with everything hanging off it
        • Grant or revoke admin
        • Verify an account by hand
        • Lock or unlock sign-in
        • Allow or hold provisioning
        • Send the verification mail again
        • Set the plan by hand
      • Platform
        • Every database and store, across all accounts
        • The counts worth putting on a dashboard
      • Staff and permissions
        • What kind of admin the caller is, and which scopes they hold
        • The admin presets
        • Every admin, and the scopes each resolves to
        • Make an account an admin of a kind
        • Change an admin's kind, or a custom admin's scopes
        • Revoke admin
      • Team administration
        • Every team, with its owner and size
        • One team in full — members with their accounts, and projects
        • Hand the team to another member
        • Put an account in the team, active at once
        • Change a member's role
        • Remove a member
      • Assumed identities
        • Assume an identity — act as a customer account
        • The identities the caller holds open
        • End an assumed identity
      • Support inbox
        • The platform's addresses
        • Add a platform address
        • One address, with its counts
        • Change an address's label, routing, or whether it accepts mail
        • Remove an address that holds no mail
        • Make, adopt or repair the address's routing rule
        • Every admin's personal address, made where missing
        • File a raw message, as if it had arrived
        • The inbox
        • One message, whole
        • Mark a message
        • Hand a message to an admin, or take it back
        • Leave an internal note
        • Reply, from the address the message came to
        • What is waiting
    • Databases
      • Databases
        • Data stores
          • Every data store on the account
          • Provision a data store
          • Change a data store that is already running
          • One data store
          • Deprovision it
          • Report what it is using
          • How to reach a data store
        • Connections and credentials
          • The api keys on a database
          • Issue a key
          • Revoke a key
          • The engine users we created
          • Create a user inside the database
          • Give a user a new password
          • Drop a user
          • How the database is reached
          • Set how it is reached
          • Revoke every api key on it
          • How to connect to it
          • The ssh keys that open its bastion
          • Add a key, or have one made
          • The private half of a generated key, once
          • Revoke an ssh key
        • Schema and objects
          • The SQL objects we put in it
          • Create one, and apply it
          • What is in the database, as the engine reports it
          • One table
          • What it would run, without running it
          • Redefine an object in place
          • Drop it
          • The extensions installed in it
        • Migrations
          • A plan compiled to statements, recording nothing
          • The migrations recorded for it
          • Record one
          • Apply everything recorded and not yet run
          • Apply everything pending, oldest version first
        • Lifecycle
          • Grow or shrink it
          • Stop it accepting connections
          • Start it again
          • Move it to another region
        • Backups and replicas
          • Take a backup
          • The backups it has
          • Restore one into it
          • What has been restored into it, and from where
          • Its read copies
          • Add one
        • Policies
          • The catalog
          • One policy
          • What this database is held to
          • Switch a policy on, or change its knobs
          • Switch it off
          • Mark the policies reviewed
          • Evaluate the periodic policies now
          • Who is behind a credential
        • Passwordless
          • The database's passwordless sessions
          • Issue a passwordless session (v1)
          • Revoke a passwordless session
          • Bind a session to a device key
          • Issue a v2 session for a host to bind
          • Redeem an enrolment ticket on the host
          • Check a passwordless credential
          • Run one described query under a passwordless credential
        • Database roles
          • The database's roles
          • Define a role
          • One role
          • Change what a role permits
          • Delete a role, and revoke every key granted it
          • Grant a role, which mints a key
          • Who holds a role
          • The api keys bound to a role
        • Pools
          • A database's connection pools, and how busy each is
          • Open another pool
          • Rename a pool or change its size
          • Close a pool
        • Data plane
          • Run a query against a database
          • Several statements, in order, over one connection
          • Is this database answering
          • What the proxy itself is holding
        • Transfers
          • The strategies this account's tier may use
          • Price a transfer without creating it
          • The account's transfer jobs, newest first
          • Create a transfer job
          • One job, and whether its certificate holds
          • Cancel an unfinished job
          • Start a created job, confirming its price
          • Report a job's status (the runner, or the owner's side of a local run)
        • Every database on the account
        • Provision a database
        • Change a database that is already running
        • One database
        • Deprovision it
        • Read or write it
        • Report what it is using
        • The engines this API will provision
        • Where a managed database can be put
    • Teams
      • Teams
        • Members
          • Who is on it
          • Invite somebody
          • Accept an invitation
          • Change somebody's role
          • Remove somebody
        • Projects
          • The projects a team runs
          • Start one
          • Rename a project, or move its environment
          • Delete a project
          • One project, and what is in it
          • Put a database or store in it
          • What is in a project
          • Take one out
        • The teams this account is on
        • Start one
        • The twelve roles, and what each may do
        • Rename a team
        • Close a team
        • One team
        • Hand the team to somebody else
        • Leave a team
    • Schemas
      • Error
      • ValidationErrors
      • Token
      • PolicyCategory
      • PolicySeverity
      • PolicyAction
      • PolicyTrigger
      • PolicyParam
      • Policy
      • PolicyCatalog
      • PolicyAssignment
      • PolicyOutcome
      • PolicyFiring
      • CredentialTrace
      • Relation
      • Restrictions
      • ChildSummary
      • SignupRequest
      • SigninRequest
      • UpdateMeRequest
      • User
      • Address
      • GeoPoint
      • D1Region
      • Usage
      • UsageReport
      • Resource
      • Database
      • DataStore
      • NewDatabase
      • NewDataStore
      • QueryRequest
      • DatabasePrivilege
      • ApiKey
      • DbUser
      • Access
      • AccessRequest
      • DatabaseObject
      • NewDatabaseObject
      • NewMigration
      • SchemaOperation
      • Migration
      • Operation
      • Session
      • AdminPowers
      • Admin
      • AuditEntry
      • TeamRole
      • Conversation
      • ChatMessage
      • TeamSso
      • Team
      • TeamMember
      • SshKey
      • ConnectionInfo
      • InboxAddress
      • InboxMailbox
      • InboxMessageSummary
      • InboxMessage
      • InboxNote
      • Page
      • Quote
      • Plan
      • ApiError
      • CapabilityRefusal
      • KlockErrorCode
      • PageWindow
      • SessionView
      • KlockStepUpRequest
      • KlockOpenRequest
      • KlockOpenedSession
      • UpdateMeResult
      • CapabilitiesView
      • SessionLimits
      • Device
      • DeviceListed
      • DeviceTeamRef
      • DeviceCommand
      • DeviceAuditLine
      • DeviceActionRequest
      • StaffActionRequest
      • ManagedSession
      • SessionRevoked
      • AccessRule
      • AccessRuleRequest
      • BehaviourProfile
      • BehaviourProfileRequest
      • BehaviourProfileSaved
      • BehaviourFinding
      • BehaviourFindingAck
      • StepUpProof
      • Removed
      • MemberLockdownRequest
      • McpClient
      • McpDelegatedView
      • McpToolCall
      • McpForwardRequest
      • PasswordlessScope
      • RoleScope
      • PasswordlessSession
      • PasswordlessTlsMaterial
      • PasswordlessKerberosMaterial
      • ServiceDeviceKeys
      • ServiceSession
      • DatabaseRole
      • TransferEndpoint
      • TransferJobRequest
      • TransferQuote
      • TransferStrategy
      • TransferJob
      • TransferJobAccess
      • Pool
      • PoolAllowance
      • BillingSubscription
      • Invoice
      • BillingContact
      • PaymentMethod
      • UpcomingCharges
      • TeamBilling
      • TeamKerberosRealm
      • TeamSsoDomain
      • TeamSsoResponse
      • StoreConnection
      • BatchFailure
      • ProxyStatus
  1. guides

Views, triggers & other schema objects

Beyond tables, indexes and constraints, a database holds views, materialized
views, sequences, triggers, stored procedures, functions, row-level security
policies and partitions. Prorm can create all of them, from three places:
1.
prorm.* — imperative methods on the connection.
2.
prorm.getQueryInterface() — the same operations, dialect-normalised,
and what migrations use.
3.
Decorators — @View, @MaterializedView, @Trigger, @Procedure,
@SqlFunction declared on a class and applied by sync().
Where an engine genuinely lacks a feature, the builders throw
UnsupportedSchemaObjectError with an explanation rather than emitting SQL the
driver will reject. That is the design: fail with a reason at the call site, not
with a syntax error from the server.
[!TIP]
For objects defined as typed TypeScript values (views built with from(),
sqlFunction/sqlProcedure routines, 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."
  }
}

Views#

A view is a saved query. Describe it with the same options you would pass to
findAll() and the dialect compiles the SELECT for you — there is no SQL to
write:
const 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 the model itself#

When the view selects from one model, put the call on that model — from is
implied and drops out:
await Customer.createView('customer_info', {
  attributes: ['firstName', 'lastName', 'email'],
  where: { country: 'USA' },
});
That is the same view as the qi.createView() call above; the model supplies
from. Omit the definition entirely for a view of the whole table, and pass
from 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');
The rest of a view's life is on the model too, so nothing created there has to
be finished through the query interface:
await Customer.viewExists('customer_info');                       // boolean
await Customer.refreshMaterializedView('customer_totals', { concurrently: true });
await Customer.materializedViewExists('customer_totals');
await Customer.dropMaterializedView('customer_totals');
Available on every model, however it was defined — prorm.define(),
prorm.addModel() or the decorators. A model that was never registered says so
rather than failing with "not a function".

Selecting from anywhere#

from takes a model class or a plain table name. Everything else — where,
include, group, having, order, limit, distinct, cte, union —
means exactly what it means in a query, because it is the query: the body is
built by the same buildSelectQuery() that runs your findAll() calls. So the
view is quoted for the database you are connected to, and a definition that
works on PostgreSQL works on MySQL.

Values are escaped, not concatenated#

A CREATE VIEW cannot carry bind parameters — the definition is stored in the
catalog, so its values have to be literals. Prorm folds them in through the
dialect's own escaping rather than string concatenation, which is why a value
like O'Brien is safe:
await qi.createView('irish_customers', { from: Customer, where: { name: "O'Brien" } });
// SELECT * FROM "customers" WHERE "name" = 'O''Brien'

Aggregates and expressions#

Aggregate views need no SQL either — fn(), col() and cast() cover the
expression forms, and [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"

Declaratively, with a decorator#

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 here
Compiling a definition needs a live dialect, so ViewRegistry.getSQL() wants
one when you call it directly — ViewRegistry.createView(prorm, ActiveUsers)
takes it from the instance. The view name defaults to the class name in
snake_case.

The raw SQL form (deprecated)#

Deprecated: passing a SELECT string to createView(), and the decorator's
query and queryFn options. Use definition instead. 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'");
A hand-written string is not portable across dialects, does not quote
identifiers for you, and puts you back in the business of escaping values by
hand — the three things definition exists to take care of.

Materialized views#

Materialized views take the same structured 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 throws
Materialized views are only supported in PostgreSQL elsewhere. The
QueryInterface versions delegate straight to the dialect, so Oracle's
materialized views — with FAST / COMPLETE / FORCE refresh methods and
ON COMMIT / ON DEMAND schedules from RefreshOptions — are reachable
through prorm.getQueryInterface() or the dialect instance.
@MaterializedView declares one on a class, with refreshStrategy defaulting
to 'ON DEMAND'. It accepts definition exactly as @View does.
Deprecated: query and queryFn on @MaterializedView, and the query
option on prorm.createMaterializedView() / qi.createMaterializedView().
Pass definition instead.
A model mapped onto a materialized view gets Model.refresh() and
Model.isMaterializedView().

The fixed vocabularies#

Where a field takes one of a handful of words, there is an enum for it:
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,
});
Every one of those fields still accepts the plain string, so
{ type: 'UNIQUE' } and { type: ConstraintType.Unique } are the same value.
Enums and constants lists them all, with what each member means
and which engines support it.

Addressing a model instead of a table#

Every operation below exists on prorm.getQueryInterface(), where the first
argument is a table name. When you have the model, that argument is redundant —
and it is a second place for the table name to live, one a tableName option or
a schema prefix can quietly make wrong. So each is also a method on the model:
await qi.addColumn('Articles', 'slug', { type: DataTypes.STRING });  // by table
await Article.addColumn('slug', { type: DataTypes.STRING });         // by model
The model supplies its own resolved table name, so the two can never disagree.
On the modelDelegates 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)
Options objects that carry a table (or parentTable) get it filled in, and
passing one explicitly still wins — so a call can sit on the model while acting
somewhere else:
await 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' });
These are pure addressing — each delegates straight to the QueryInterface, so
behaviour, dialect support and error messages are identical. Use whichever reads
better: the model form in application code, the table form in migrations, where
the model may not exist yet.
Note: Article.describeTable() reports the table as the database has it,
which is what qi.describeTable() returns. Article.describe() is a
different, older method that returns the model's own attribute definitions.

Constraints, policies and indexes without SQL#

The remaining places that stored a SQL fragment now take the same where
syntax a query does. Values are escaped and identifiers quoted for the
connected database, exactly as in a view.

Check constraints#

await 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 CHECK with no predicate used to emit CHECK (1=1) — a constraint
that silently enforced nothing. It now throws instead.

Row-level security#

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 be
written. Both take a where object or, still, a raw string.

Expression indexes#

Index a function of a column with the expression helpers rather than a 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")
});

Triggers#

A trigger has a small fixed vocabulary, so it is enumerated rather than typed
out — 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 a
model or a name.

What it does#

body takes actions rather than SQL. Each renders differently per engine, which
is the reason they are worth naming:
ActionMeans
{ 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
Pass an array to run several in order. NEW('column') and OLD('column') refer
to the incoming and previous rows, quoted correctly for each dialect:
await 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 no NEW row at all and says so.

When it fires#

when is a where object. A bare key names a column of the incoming row,
so this fires only when the status actually changed:
when: { status: { ne: OLD('status') } }     // WHEN NEW."status" <> OLD."status"
An 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
behind findAll(). That builder binds values, and a trigger's WHEN is
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, and and / or / not; anything
else throws, and a string when is always available.

Declaratively#

@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 {}

Procedures and functions#

@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 takes
IMMUTABLE / STABLE / VOLATILE, MySQL takes
DETERMINISTIC / NOT DETERMINISTIC, and they are not interchangeable.
Their body stays a string, and that is not an oversight: a stored procedure is
imperative code with control flow and local state, so there is nothing to build
it from. For logic that does not belong in SQL, write it in another language and
register it with createExternalFunction() above.

Extensions#

await qi.createExtension('uuid-ossp');
await qi.createExtension('postgis', { cascade: true });
The name is quoted, which is not cosmetic: CREATE EXTENSION uuid-ossp is a
syntax error because of the hyphen, and that is the usual reason a hand-written
one fails the first time.
PostgreSQL family only. MySQL loads plugins with INSTALL PLUGIN, a
server-level operation; SQLite loads extensions through the driver rather than
through SQL. Both say so rather than emitting something the driver rejects.

Enum types and domains#

await 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 member
sooner or later, and recreating the type would mean dropping every column that
uses it — so this was previously a hand-written ALTER TYPE.
A domain is a named type carrying its own constraints, declared once
instead of on every column:
await qi.createDomain('email', {
  as: 'text',
  notNull: true,
  check: "VALUE ~ '@'",          // VALUE stands for the value being checked
  constraintName: 'email_shape',
});
A domain's check is the one predicate in this guide still written as SQL, and
deliberately: it constrains the pseudo-column VALUE rather than a column, so
there is nothing for a where object to name. A CHECK on a table is the
object form — addConstraint('users', { type: ConstraintType.Check, check: { age: { gte: 0 } } }).
Both are PostgreSQL-only as named types. MySQL's ENUM is a column type
written inline, and neither MySQL nor SQLite has domains — on those, the
equivalent is a CHECK constraint, which addConstraint() builds.

Comments#

await Article.comment('Published posts, one row per revision');
await Article.commentOnColumn('slug', 'URL segment; unique per locale');
await Article.comment(null);      // remove it
Comments live in the catalog, so they survive a restore and appear in every
client — a better home for "why does this column exist" than a wiki page that
nobody updates.
Note: 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 at changeColumn(),
which has them. A table comment works on both.

Sequence control#

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 ids
leave the sequence behind them, and the next ordinary insert fails on a
duplicate key — a failure that shows up hours later and reads like corruption.
Note: setval takes the sequence name as a string literal, not an
identifier, which is another detail hand-written calls get wrong. Passing
isCalled: false makes the next value the one you gave, rather than one past
it.
MySQL has no sequences — reset a counter with
ALTER TABLE … AUTO_INCREMENT = …; SQLite tracks the last rowid in
sqlite_sequence. Both are named in the error.

Maintenance#

The statements you run about a table rather than against its rows. Every
engine has them and no two agree on the spelling, so these take the intent:
await Article.analyze();            // refresh the planner's statistics
await Article.vacuum({ full: true });
await Article.reindex();
await Article.checkTable();
CallPostgreSQLMySQLSQLiteSQL Server
analyze()ANALYZEANALYZE TABLEANALYZEUPDATE STATISTICS
vacuum()VACUUM—VACUUM (whole db)—
reindex()REINDEX—REINDEX—
optimizeTable()—OPTIMIZE TABLE——
checkTable()—CHECK TABLEPRAGMA integrity_check—
repairTable()—REPAIR TABLE——
checksumTable()—CHECKSUM TABLE——
cluster()CLUSTER———
A dash is not a silent no-op: the call throws naming the engine, and where there
is a real alternative it says so — asking MySQL to vacuum() points you at
optimizeTable(), and asking PostgreSQL to optimizeTable() points back at
vacuum({ full: true }).

Which of these you actually need#

analyze() is the one worth running routinely. A planner working from stale
statistics chooses bad plans, and that is the usual reason a query that was fast
last month is not. Run it after a bulk load especially: statistics taken when
the table was empty describe an empty table.
vacuum() matters on engines that keep old row versions. That is why MySQL
has none — InnoDB reclaims space through its own purge threads. Plain VACUUM
returns space for reuse within the table and does not block reads;
{ full: true } rewrites the table and returns space to the operating system,
taking an ACCESS EXCLUSIVE lock on PostgreSQL — the table cannot even be read
while it runs.
await Article.vacuum({ full: true, analyze: true });   // VACUUM (FULL, ANALYZE)
await qi.vacuum(undefined, { into: '/backups/snap.db' }); // SQLite: a consistent copy
VACUUM INTO is worth knowing: it writes a compacted copy and leaves the
original alone, which is the supported way to back up a live SQLite database.
cluster() rewrites a table in one index's physical order. Worth it when a
table is read in a predictable order and the index is not covering — though the
ordering decays as rows are updated, so it is a periodic job rather than a
setting.
Note: checkTable() on SQLite checks the whole database, because
PRAGMA integrity_check has no per-table form. PostgreSQL has no built-in
check at all; the error names the amcheck extension rather than pretending.

Functions written in another language#

Sometimes the logic does not belong in SQL — you have C you want the database to
call, or you would rather write the transformation in Python or JavaScript.
createExternalFunction() registers either kind, and afterwards it is just a
function you can call from a query.

Compiled (C)#

You compile to a shared library; the database loads it. This is the MySQL case:
await qi.createExternalFunction({
  name: 'levenshtein',
  language: 'c',
  returns: 'INTEGER',
  library: 'my_udf.so',
});
// CREATE FUNCTION `levenshtein` RETURNS INTEGER SONAME 'my_udf.so'
MySQL resolves library against the server's plugin_dir, so pass a bare file
name. Loadable functions may return only STRING, INTEGER, REAL or
DECIMAL — anything else is rejected here rather than by the server — and the
statement carries no parameter list, because a loadable function checks its own
arity in its _init routine. Add aggregate: true for a GROUP BY-capable
function.
PostgreSQL wants the object file and the exported symbol, and does take
parameters:
await 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
});

Interpreted (Python, Perl, JavaScript)#

Here the source lives in the database and a procedural-language handler runs 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,
});
Then use it like anything else — including from the ORM, with no SQL:
await Article.findAll({ attributes: ['title', [fn('slugify', col('title')), 'slug']] });
source is code in that language: it is stored verbatim and never parsed as
SQL. It is dollar-quoted, and the tag widens automatically if your source
happens to contain it, so a body cannot terminate its own literal.
LanguagelanguageWhere 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
The language handler has to be installed in the database first
(CREATE EXTENSION plpython3u), and MySQL supports only the compiled form —
asking it for plpython3u throws a message saying so rather than emitting SQL
the server will reject.
Drop one with dropExternalFunction(name, { params }); PostgreSQL identifies
overloads by argument types, so pass the same params when several exist.

Sequences#

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 });
DialectSQL
PostgreSQLCREATE SEQUENCE IF NOT EXISTS "order_seq" START WITH 1000 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 CACHE 20 CYCLE
MariaDBidentical (MariaDB 10.3+)
Oraclesame without IF NOT EXISTS — Oracle has no such clause
SQL Serversame without CACHE
MySQLthrows — "MySQL has no sequences - use an AUTO_INCREMENT column instead."
SQLitethrows — "SQLite has no sequences - use an INTEGER PRIMARY KEY AUTOINCREMENT column instead."
DROP SEQUENCE omits IF EXISTS on Oracle for the same reason.

Keep the code in a file#

A C function or a Python transform is code. In a TypeScript string literal it
loses highlighting, its formatter, its compiler, and a git blame that points
at the function rather than at a template literal — and every quote has to be
escaped by hand. So point at the file:
await 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 — the
last being how a page lets someone upload a function rather than type it into a
textarea. A network URL is refused: fetching a function body at schema-build
time is not something to do by accident.
Supplying both source and file is an error rather than a silent preference,
since which one wins would otherwise depend on an implementation detail.
The decorators take it too, and @FromFile reads better when the rest of the
declaration is short:
class Reports {
  @Procedure({ name: 'rebuild_totals' })
  @FromFile('./db/procedures/rebuild_totals.sql')
  rebuildTotals!: () => Promise<void>;
}
Note: decorators apply bottom-up, so @FromFile must sit below the
routine decorator that consumes it. It builds its DDL synchronously, so a
File/Blob — readable only asynchronously — is rejected with that reason;
pass a path there.

Triggers#

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');
One declaration, five different shapes. PostgreSQL needs a trigger function,
MySQL has no WHEN, SQLite has no FOR EACH, and SQL Server has no BEFORE
triggers at all:
UnsupportedSchemaObjectError: 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 the when condition 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 typed defineTrigger, which moves a
when guard into the body for you.
The decorator form is applied by 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-level
equivalents.

Stored procedures and functions#

Procedures are declared on plain classes — they are not models — so the
connection has to be told about them:
import { 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 here
Calling and dropping them at runtime goes through the dialect:
const 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 a
single call for procedures, functions and events.

Row-level security#

PostgreSQL-family only:
await 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');
On MySQL it throws; on SQL Server it throws with the pointer that SQL Server
uses CREATE SECURITY POLICY with a predicate function instead. Oracle's VPD
and SQL Server's security policies are reachable through their own dialects.
For an application-level equivalent that works everywhere, see the
RowLevelSecurity and SessionIsolation modules in
Compliance.

Partitions#

await 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 take
partitionName — the asymmetry is real, not a typo here.)
bound covers all three schemes: { from, to } for RANGE, { values } for
LIST, { modulus, remainder } for HASH.
RANGE, LIST and HASH are supported, with the syntax each engine uses —
declarative partitioning on PostgreSQL, PARTITION BY clauses on MySQL/MariaDB,
and a base table plus child tables joined by a union view on SQLite. The same
four calls exist on the QueryInterface.

Schemas and databases#

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');
Set schema on a model (or searchPath on a query) to target one.

Extensions#

await prorm.createExtension('postgis', { ifNotExists: true, schema: 'public' });
await prorm.hasExtension('postgis');
await prorm.getExtensions();
await prorm.dropExtension('postgis', { cascade: true });
For a searchable catalogue of what extensions exist per engine, see
Extensions.

The typed SQL-objects library#

Everything above takes SQL text or findAll-style options and hands the result
to one call. The SQL-objects library (ts-prorm-orm/sql, also reachable as the
Sql namespace of the root package) goes further: views, materialized views,
routines, triggers, sequences, enums, domains and policies are typed values
built in TypeScript
. Prorm compiles each one per engine, reports what an engine
cannot do before it touches the database, and sync() creates, replaces or
prunes them, recording what it made.
ObjectDefine withRegister with
View@View(query) on a ReadonlyModel, or Model.asView(name, query)the model itself
Materialized view@MaterializedView(query, { refresh })the model itself
FunctionsqlFunction(spec, body) as a static of a classprorm.use(Group)
ProceduresqlProcedure(spec, body) as a static of a classprorm.use(Group)
TriggerdefineTrigger(Model, spec, body) or @Trigger(spec, body)prorm.sqlObjects.addObject(trigger.def)
SequencedefineSequence({ name, start, … }), or @Sequence on a columnprorm.use(objectsPlugin(…))
Enum typedefineEnum(name, values)objectsPlugin(…)
DomaindefineDomain(name, baseType, check)objectsPlugin(…)
Row-level security policydefinePolicy(Model, { using, withCheck })objectsPlugin(…)
Comments, checks, guards@Description, @Between, @Pattern, @Immutable …prorm.use(describePlugin())
[!NOTE]
The examples import Sql from ts-prorm-orm. In an application you can
import the same names directly: import { from, fn, t, sqlFunction } from 'ts-prorm-orm/sql'.

The example schema#

Every example in this section uses these four models:
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;
}

Typed queries#

View bodies, trigger subqueries and ad-hoc reports all use one query builder.
from(Model, alias) gives a scope whose columns are typed from the model, so a
misspelt column or a string compared with a number is a compile error:
import { 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.

Typed views#

A typed view is a read-only model whose body is a query. The class declares the
row shape, and the decorator checks it against what the query selects:
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 model
(CustomerSummary.findAll({ where: { email } })); writes are refused.
For a one-model filter, derive the view from the model instead of writing a
class. The row type is the model's attributes:
const ActiveUser = User.asView('active_users', (q) => q.where(({ u }) => u.status.eq('active')));

Typed materialized views#

@MaterializedView takes the same query plus how the stored rows are kept
current. Engines without native materialized views get the closest equivalent,
and the plan says which one:
import { 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;
}
EngineWhat sync() builds
PostgreSQLCREATE MATERIALIZED VIEW … WITH DATA plus the unique index
MySQLa table filled by CREATE TABLE … AS SELECT, a prorm_refresh_user_revenue procedure and, for Refresh.every(), an EVENT that calls it
SQLitea table filled by CREATE TABLE … AS SELECT, refreshed by the app
SQL Serveran indexed view (WITH SCHEMABINDING plus a unique clustered index)
Refresh.onDemand() (the default), Refresh.onCommit(), Refresh.incremental()
and Refresh.every('30s' | '15m' | '1h' | '1d' | '1w') pick the refresh mode.
The generated DDL on each engine
[!WARNING]
Emulations are not free of differences, and the plan reports each one. A MySQL
EVENT only runs with event_scheduler=ON (a MATERIALIZED_VIEW_REFRESH
warning). A SQL Server indexed view wraps SUM arguments in ISNULL(…, 0), so
a group whose values are all NULL sums to 0 rather than NULL (a
SEMANTIC_DIVERGENCE note).

Routines: sqlFunction and sqlProcedure#

A routine's body is TypeScript that builds statements: $.declare, $.if,
$.update, $.insert, $.return, $.raise, $.rowCount() and so on. It is
lowered to PL/pgSQL, MySQL's SQL/PSM, T-SQL, PL/SQL and the other routine
languages, so one definition serves every engine that has routines.
Put routines on a class as statics. The class is a routine group, and
prorm.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 });
Decimals come back as strings ('15.00'), as they do from the drivers, so no
precision is lost.
error 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.
The procedure, cancel_stale_orders, on each engine
error OBJECT_UNSUPPORTED: sqlite has no stored procedures
[!IMPORTANT]
With binary logging on (the MySQL 8 default), CREATE FUNCTION needs SUPER
or log_bin_trust_function_creators = 1, or it fails with error 1419. The plan
flags this as ROUTINE_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 an UPDATE counts matched rows only for
clients that connect with CLIENT_FOUND_ROWS. mysql2, the ORM's driver, does;
other clients calling the same procedure get changed rows. The plan warns with
SEMANTIC_DIVERGENCE.

Triggers: defineTrigger#

defineTrigger(Model, spec, body) takes the table, the timing and events, an
optional when guard, and a body in the same statement DSL. The body receives
typed new and old rows: old does not exist in an insert-only trigger, and
n.set() is available only where the timing can be BEFORE, so those mistakes
are type errors.
import { 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);
}
The same trigger comes out quite differently per engine: PostgreSQL needs a
trigger function, MySQL and SQLite split a multi-event trigger into one trigger
per event, and SQL Server works set-based over inserted/deleted:
$.assert raises an engine-native error that carries the code, so the app can
tell TOTAL_NEGATIVE apart from any other failure:
What each engine refuses, and the plan diagnostic you get instead of SQL:
TriggerRefused onDiagnostic and hint
n.set() in a BEFORE trigger (orders_touch)SQLiteTRIGGER_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 triggerSQL ServerTRIGGER_TIMING: no BEFORE triggers; use timing: 'after', or opt in with mssqlBefore: 'after' or mssqlBefore: 'insteadOf'
several eventsMySQL, SQLitenot 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 a name, a trigger is called <table>_<timing>_<events>, for example
orders_after_ins_del.

Sequences, enums, domains and policies#

These four are defined as handles and registered together through
objectsPlugin(). applyTo(Model, attribute) binds an enum or a domain to a
column. On engines without the object type, the binding becomes a CHECK on
that column instead.
import { 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
ObjectPostgreSQLMySQLSQLiteSQL Server
defineSequenceCREATE SEQUENCErefusedrefusedCREATE SEQUENCE … AS BIGINT
defineEnum + applyToCREATE TYPE … AS ENUMinline ENUM(…) column typeCHECK triggersCHECK constraint
defineDomain + applyToCREATE DOMAINCHECK constraintCHECK triggersCHECK constraint
definePolicyCREATE POLICY + ENABLE ROW LEVEL SECURITYrefusedrefusedrefused
The PostgreSQL enum block
On PostgreSQL an enum can gain values but not lose or reorder them, so the
create step checks the existing labels before it adds anything:
Each object has create statements, which run when it does not exist yet, and
replace statements, which run when its definition changed since the last
sync(). A changed sequence gets ALTER SEQUENCE without RESTART, so the
counter is kept. A changed domain drops and re-adds its constraint. An enum that
loses or reorders a value is refused unless you pass allowRebuild: true.
[!CAUTION]
This policy reads the tenant from current_setting('app.tenant_id', true).
When the setting is missing, it compares tenantId with NULL and 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 pass
force: true.

Fallbacks for engines without routines or triggers#

SQLite has no stored procedures, DuckDB and ClickHouse have no triggers, and so
on. Rather than refusing, a routine can opt into a fallback:
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'
The interpreter is the reference implementation the engine emitters are tested
against, so the answer is the same as PostgreSQL's.
[!WARNING]
A trigger that falls back to ORM hooks fires only for writes made through
models. Raw prorm.query() statements and other clients bypass it, and the
plan warns you about this.

Describe decorators#

The describe decorators document a model once and feed the docs, JSON Schema,
codegen, log redaction and, with 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' }
DecoratorIn the appIn the database (describePlugin())
@Description(text)docs, JSON Schema description, codegen JSDocCOMMENT 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/maximumCHECK (col BETWEEN min AND max)
@Pattern(regex)a matches validator; JSON Schema patterna regex CHECK where the engine has one
@Immutableupdates refused with a ValidationErrora 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 productionnone
[!NOTE]
@Immutable needs UPDATE OF column triggers for its database guard, which
MySQL lacks; the plan reports TRIGGER_EVENT there. The application check
(ValidationError: Invoice.reference is immutable and cannot be updated)
applies on every engine.

Planning and syncing SQL objects#

The registry plans without a connection, so you can review the DDL for an engine
you are not running and fail CI on refusals:

Plan for one 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);

Check every engine you deploy to#

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'));

Dry-run the sync, then apply#

await prorm.sync({ objects: { dryRun: true } }); // report only
await prorm.sync({ objects: { prune: true } }); // apply; drop objects no longer defined
sync() records each object's hash in a prorm_objects table. An unchanged
object is skipped, a changed one is replaced with the engine's replace form, and
prune drops only objects that Prorm created. The same steps are available
from the command line: prorm objects plan --dialect mysql,
prorm objects check --dialect all and prorm objects apply --dry-run (see
CLI).

Related reading#

QueryInterface — the full imperative schema API
Migrations — running these changes in order, versioned
Indexes & constraints
Decorators — @View, @Trigger, @Procedure
CLI — prorm objects plan, check and apply
Compliance — what @Sensitive feeds
Dialects — per-engine support notes
Modified at 2026-10-04 00:40:16
Previous
Schema diffing & migration generation
Next
Scopes & default scopes
Built with