Lifecycle Hooks
Hooks run custom logic at points in an entity’s lifecycle: validation, timestamps, slug generation, data masking.
| Decorator | Fires when |
|---|---|
@BeforeInsert() |
Before a new record is inserted |
@AfterInsert() |
After a new record is inserted |
@BeforeUpdate() |
Before a record is updated |
@AfterUpdate() |
After a record is updated |
@BeforeDelete() |
Before a record is deleted, once per row |
@AfterDelete() |
After a record is deleted, once per row |
@AfterLoad() |
After a record is loaded from the database |
All hooks receive a HookContext with the active querier, so you can perform additional DB operations within the same transaction.
Hooks run once per payload, with this bound to it: the record for a write, the update payload for @BeforeUpdate/@AfterUpdate, each loaded row for @AfterLoad.
upsertOne/upsertMany run no hooks, and findManyStream runs no @AfterLoad. An upsert’s branch is decided by the database as the statement runs, so there is no point at which “insert” or “update” is the honest event; a stream has no point at which every row has been seen.
Entity-Level Hooks
Section titled “Entity-Level Hooks”import { Entity, Id, Field, BeforeInsert, AfterLoad } from 'uql-orm';
@Entity()export class Article { @Id({ type: Number }) id?: number;
@Field({ type: String }) title?: string;
@Field({ type: String }) slug?: string;
@Field({ type: String }) internalCode?: string;
@BeforeInsert() generateSlug() { if (this.title) { this.slug = this.title.toLowerCase().replace(/\s+/g, '-'); } }
@AfterLoad() maskInternalCode() { this.internalCode = '***'; }}Mutation Semantics
Section titled “Mutation Semantics”@BeforeInsert/@BeforeUpdate: Mutations viathisare propagated to the payload. This is how you transform data before persistence.@AfterLoad: Mutations viathisare propagated. This is how you derive a value in JavaScript and mask data after loading.after*hooks (@AfterInsert,@AfterUpdate,@AfterDelete): Side-effect only, for logging, cache invalidation, or notifications. Data is already persisted. Throwing from one reports its own failure; it does not unwrite the row unless a transaction is open.
Delete Hooks
Section titled “Delete Hooks”@BeforeDelete and @AfterDelete run once per row being deleted, with this bound to the row as it was before the delete. Both see that same snapshot, so an @AfterDelete can still name what it removed:
import { AfterDelete } from 'uql-orm';
@Entity()export class Attachment { @Id({ type: Number }) id?: number;
@Field({ type: String }) storageKey?: string;
@AfterDelete() async dropFile(this: Attachment) { await bucket.delete(this.storageKey!); }}This applies to soft deletes and to children removed by a cascade: 'delete' relation. Reading the rows back costs one extra query, paid only by entities that declare a delete hook (or pools that register a listener for the event); mutating the snapshot changes nothing, since the row is on its way out.
Async Hooks
Section titled “Async Hooks”An async hook is awaited before the operation proceeds:
import type { HookContext } from 'uql-orm';
@Entity()export class User { @Id({ type: Number }) id?: number;
@Field({ type: String }) email?: string;
@BeforeInsert() async validateEmail(ctx: HookContext) { const existing = await ctx.querier.count(User, { $where: { email: this.email }, }); if (existing > 0) { throw new Error('Email already exists'); } }}ctx.querier runs in the same transaction as the operation that triggered the hook.
Multiple Hooks Per Event
Section titled “Multiple Hooks Per Event”Several hooks for one event execute in declaration order:
@Entity()export class Post { @Id({ type: Number }) id?: number;
@Field({ type: String }) title?: string;
@Field({ type: String }) slug?: string;
@BeforeInsert() normalizeTitle() { this.title = this.title?.trim(); }
@BeforeInsert() generateSlug() { this.slug = this.title?.toLowerCase().replace(/\s+/g, '-'); }}Stacking Decorators
Section titled “Stacking Decorators”A single method can be registered for multiple events:
import { BeforeUpdate } from 'uql-orm';
@BeforeInsert()@BeforeUpdate()normalizeEmail() { if (this.email) { this.email = this.email.toLowerCase().trim(); }}Hook Inheritance
Section titled “Hook Inheritance”Hooks are inherited from parent entities. Parent hooks execute first:
class BaseEntity { @Id({ type: Number }) id?: number;
@Field({ type: Date }) updatedAt?: Date;
@BeforeInsert() @BeforeUpdate() setTimestamp() { this.updatedAt = new Date(); }}
@Entity()class Post extends BaseEntity { @Field({ type: String }) title?: string;
@BeforeInsert() validate() { if (!this.title) throw new Error('Title is required'); }}// On insert: setTimestamp() runs first, then validate()Global Listeners
Section titled “Global Listeners”For cross-cutting concerns (audit logging, automatic timestamps across all entities, cache invalidation), register global listeners on the querier pool:
import { type QuerierListener } from 'uql-orm';import { PgQuerierPool } from 'uql-orm/postgres';
const auditListener: QuerierListener = { afterInsert({ entity, payloads, querier }) { console.log(`Inserted ${payloads.length} ${entity.name} records`); }, afterUpdate({ entity, querier }) { console.log(`Updated ${entity.name} records`); }, afterDelete({ entity }) { console.log(`Deleted ${entity.name} records`); },};
const pool = new PgQuerierPool(connectionConfig, { listeners: [auditListener],});Global listeners receive a ListenerContext with:
| Property | Type | Description |
|---|---|---|
entity |
Type<E> |
The entity class |
querier |
Querier |
The active querier (same transaction) |
payloads |
E[] |
The entity payloads |
event |
HookEvent |
The event name |
Global listeners fire first, in registration order, then entity hooks in declaration order with parent hooks first. That order is what lets a listener inject audit metadata the entity hooks then read.
Hooks live at the querier layer, not the dialect, so they behave identically on every supported database.