Skip to content
NewFrom Mongoose to SQL6 min read

Lifecycle Hooks

Hooks run your code at points in an entity’s lifecycle: validating input, filling a slug, masking data after a read, sending a mail after a write.

import { Entity, Id, Field, BeforeInsert, AfterLoad } from 'uql-orm';
@Entity()
export class Article {
@Id({ type: Number }) id?: number;
@Field({ type: String }) title?: string | null;
@Field({ type: String }) slug?: string | null;
@Field({ type: String }) internalCode?: string | null;
@BeforeInsert()
generateSlug() {
if (this.title) {
this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
}
}
@AfterLoad()
maskInternalCode() {
this.internalCode = '***';
}
}
Operation Before After
insert @BeforeInsert() @AfterInsert()
update @BeforeUpdate() @AfterUpdate()
upsert @BeforeUpsert() @AfterUpsert()
delete @BeforeDelete() @AfterDelete()
load @AfterLoad()

A hook runs once per payload, with this bound to it: the record for an insert, the update payload for an update, each row for a load or a delete. Populated rows run @AfterLoad too, children before their parent, and findManyStream runs it on each row as it arrives.

upsertOne and upsertMany run only the upsert pair, never insert or update, because the database picks the branch while the statement runs. saveOne and saveMany run the upsert pair for a row that includes its key, and the insert pair for one that does not.

In @BeforeInsert and @BeforeUpdate, changes to this are written. In @AfterLoad, they reach the result, which is how you derive or mask values.

The after* write hooks are for side effects such as logging, cache invalidation or notifications. this is a copy of the row as written, so @AfterInsert sees the generated id and every onInsert value, @AfterUpdate every onUpdate value, and @AfterUpsert the id the database reported. The object you passed is left alone. Throwing from an after-hook fails the call, but the row stays written unless a transaction is open.

Every hook receives a HookContext. Its querier is the one running the write, so the hook’s queries join its transaction. Its onCommit defers a callback until the transaction commits. An after-hook runs before the commit, so a mail sent straight from it would go out even if a rollback undoes the row. async hooks are awaited.

import type { HookContext } from 'uql-orm';
@Entity()
export class User {
@Id({ type: Number }) id?: number;
@Field({ type: String }) email?: string | null;
@BeforeInsert()
async validateEmail({ querier }: HookContext) {
if (await querier.count(User, { $where: { email: this.email } }))
throw new Error('Email taken');
}
@AfterInsert()
async welcome({ onCommit }: HookContext) {
await onCommit(() => mailer.welcome(this.email));
}
}

@BeforeDelete and @AfterDelete both see the row as it was before the delete, 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 | null;
@AfterDelete()
async dropFile(this: Attachment) {
await bucket.delete(this.storageKey!);
}
}

They also run for soft deletes and for children removed by a cascade: 'delete' relation. Reading the rows first costs one extra query, paid only when a delete hook or listener exists.

Hooks for one event run in declaration order, and one method can serve several events. A parent’s hooks are inherited and run first:

class BaseEntity {
@Id({ type: Number }) id?: number;
@Field({ type: Date }) updatedAt?: Date | null;
@BeforeInsert()
@BeforeUpdate()
setTimestamp() {
this.updatedAt = new Date();
}
}
@Entity()
class Post extends BaseEntity {
@Field({ type: String }) title?: string | null;
@BeforeInsert()
validate() {
if (!this.title) throw new Error('Title is required');
}
}
// on insert: setTimestamp(), then validate()

For logic that applies to every entity, such as audit logging, register listeners on the pool:

import { type QuerierListener } from 'uql-orm';
import { PgQuerierPool } from 'uql-orm/postgres';
const auditListener: QuerierListener = {
afterInsert({ entity, payloads }) {
console.log(`Inserted ${payloads.length} ${entity.name} records`);
},
};
const pool = new PgQuerierPool(
{ connectionString: process.env.DATABASE_URL },
{ listeners: [auditListener] },
);

A listener receives the entity class, the payloads, the event name and the querier running the write. Listeners run before entity hooks, in registration order, so a listener can set audit metadata the hooks then read.

Hooks and listeners run in uql, so they behave the same on every engine but do not see writes made outside uql. For those, use a trigger.