Skip to main content

Lifecycle Hooks

Every SQL service exposes a set of protected _before* / _after* methods that are called around each database operation. Override them in your subclass to inject business logic without wrapping every call site.

The base implementations do nothing — there is no need to call super.


Available hooks

HookWhen it runs
_beforeCreate(command)Before inserting a row
_afterCreate(command, result)After insert and fetch-back
_beforeUpdate(command)Before a single-row update
_afterUpdate(command, result)After update and optional fetch-back
_beforeUpdateMany(command)Before a multi-row update
_afterUpdateMany(command, affected)After a multi-row update
_beforeDelete(command)Before deleting a single row
_afterDelete(command, affected)After deleting a single row
_beforeDeleteMany(command)Before deleting multiple rows
_afterDeleteMany(command, affected)After deleting multiple rows

Accessing the command object

The command argument passed to every hook contains the full context of the operation:

command.crud // 'create' | 'read' | 'update' | 'delete'
command.method // 'create' | 'update' | 'updateMany' | 'delete' | ...
command.documentId // key of the target row (where applicable)
command.input // the patch/input data — can be mutated in _before* hooks
command.options // the operation options — can be mutated in _before* hooks

Mutating command.input in a _before* hook is the standard way to inject computed fields before the data reaches the database.


Real-world examples

Multi-tenant customer table

Stamp ownership on create and keep updatedAt current on every update:

export class CustomersService extends SqbCollectionService<Customer> {
constructor(db: SqbClient) {
super(Customer, {
db,
commonFilter: (_, _this) => ({ tenantId: _this.context.tenantId }),
});
}

protected override async _beforeCreate(command: SqbEntityService.CreateCommand<Customer>) {
command.input.tenantId = this.context.tenantId;
command.input.createdAt = new Date();
command.input.updatedAt = new Date();
}

protected override async _beforeUpdate(command: SqbEntityService.UpdateOneCommand<Customer>) {
if (command.input) command.input.updatedAt = new Date();
}
}

Input validation

Run custom validation that goes beyond schema constraints — for example, checking uniqueness:

protected override async _beforeCreate(command: SqbEntityService.CreateCommand<Customer>) {
const exists = await this.existsOne({ filter: { email: command.input.email } });
if (exists) throw new ConflictError(`Email ${command.input.email} is already registered`);
}

Audit logging

Record who changed what and when in a separate audit table:

protected override async _afterCreate(
command: SqbEntityService.CreateCommand<Customer>,
result: PartialDTO<Customer>,
) {
await this.auditLog.for(this).createOnly({
action: 'create',
resourceId: result.id,
userId: this.context?.userId,
at: new Date(),
});
}

protected override async _afterDelete(
command: SqbEntityService.DeleteOneCommand,
affected: number,
) {
if (affected) {
await this.auditLog.for(this).createOnly({
action: 'delete',
resourceId: command.documentId,
userId: this.context?.userId,
at: new Date(),
});
}
}

Cache invalidation

Bust a cache whenever a row is mutated:

protected override async _afterUpdate(
command: SqbEntityService.UpdateOneCommand<Customer>,
result: PartialDTO<Customer> | undefined,
) {
if (result) {
await this.cache.del(`customer:${command.documentId}`);
}
}

Full API reference

SqbEntityService — lifecycle hooks