Skip to content
NewComposite primary keys5 min read

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.

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 = '***';
}
}
  • @BeforeInsert / @BeforeUpdate: Mutations via this are propagated to the payload. This is how you transform data before persistence.
  • @AfterLoad: Mutations via this are 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.

@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.

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.

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

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();
}
}

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()

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.