> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# Lifecycle Hooks

> Run your own code before and after inserts, updates, upserts, deletes and loads, per entity or for every entity.

Source: https://uql-orm.dev/entities/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.

```ts
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 = '***';
  }
}
```

## Events

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

## What a hook can change

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.

## The hook context

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](https://uql-orm.dev/querying/transactions.md#after-the-commit-oncommit). 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.

```ts
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));
  }
}
```

> **Caution**
>
> While `findManyStream` holds a querier, a hook cannot query through it. Use another querier.

## Delete hooks

`@BeforeDelete` and `@AfterDelete` both see the row as it was before the delete, so an `@AfterDelete` can still name what it removed:

```ts
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.

## Order

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

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

## Global listeners

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

```ts
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](https://uql-orm.dev/entities/triggers.md).
