Skip to content

Database Events

Stratal automatically emits events for every database operation. The EventEmitterPlugin is auto-registered by the DatabaseModule, so no additional setup is required. These events integrate with the core event system and support the same pattern matching, priority, and blocking behavior.

Two families of events are emitted:

  • Query events (before.{Model}.{operation} / after.{Model}.{operation}) wrap the raw query and carry the operation arguments and result.
  • Entity mutation events (entity.{Model}.{verb}) carry full entity snapshots taken before and after the mutation.

Query events follow the pattern {phase}.{Model}.{operation}:

SegmentValuesExample
Phasebefore, afterbefore, after
ModelAny model name from your schemaUser, Post
Operationcreate, update, delete, findMany, count, etc.create

A full event name looks like after.User.create or before.Post.update. These events fire for read operations too, such as after.User.findMany.

Use the @Listener() and @On() decorators from the core event system:

import { Listener, On } from 'stratal/events'
import type { EventContext } from 'stratal/events'
@Listener()
export class UserEventListener {
@On('after.User.create')
async onUserCreated(context: EventContext<'after.User.create'>) {
const user = context.result
// send welcome email, create audit log, etc.
}
@On('before.User.delete')
async onBeforeUserDelete(context: EventContext<'before.User.delete'>) {
const data = context.data
// archive user data before deletion
}
}

Register the listener in a module’s providers array:

@Module({
providers: [UserEventListener],
})
export class UsersModule {}

Database events support the same hierarchical pattern matching as core events:

Listen to all operations on a specific model:

@On('after.User')
async onAnyUserChange(context: EventContext<'after.User'>) {
// context.operation tells you which operation triggered the event
console.log(`User ${context.operation} completed`)
}

Listen to a specific operation across all models:

@On('after.create')
async onAnyCreate(context: EventContext<'after.create'>) {
// context.model tells you which model was created
console.log(`${context.model} created`)
}

Listen to all events in a phase:

@On('after')
async onAnyAfterEvent(context: EventContext<'after'>) {
console.log(`${context.model}.${context.operation} completed`)
}

The query event context is a discriminated union based on the event pattern:

interface ExactEventContext {
data: UserCreateInput // before: mutable, after: readonly
result: User // only present in the after phase
}
interface ModelWildcardContext {
operation: DatabaseOperation // 'create' | 'update' | 'delete' | ...
data: unknown
result: unknown
}
interface OperationWildcardContext {
model: ModelName // 'User' | 'Post' | ...
data: unknown
result: unknown
}
interface PhaseWildcardContext {
model: ModelName
operation: DatabaseOperation
data: unknown
result: unknown
}

Query events carry the raw operation arguments and result, which are not always the shape you want to react to. A delete carries the where filter, an update carries only the changed fields, and a createMany carries an array. When you want to work with complete entity rows, listen for entity mutation events instead.

Entity events follow the pattern entity.{Model}.{verb}:

SegmentValuesExample
ModelAny model name from your schemaUser, Post
Verbcreated, updated, deletedcreated

A full event name looks like entity.User.created or entity.Post.updated. One event is emitted per affected row, so a bulk operation that touches many rows emits one entity event per row.

Each entity event carries model, action, and a pair of full entity snapshots. Which snapshots are populated depends on the verb:

// entity.User.created
interface EntityCreatedContext {
model: 'User'
action: 'created'
before: undefined // nothing existed beforehand
after: User // the created row
}
// entity.User.updated
interface EntityUpdatedContext {
model: 'User'
action: 'updated'
before: User // the row as it was before the update
after: User // the row after the update
}
// entity.User.deleted
interface EntityDeletedContext {
model: 'User'
action: 'deleted'
before: User // the row as it was before deletion
after: undefined // nothing remains
}
@On('entity.User.created')
async onUserCreated(context: EventContext<'entity.User.created'>) {
const user = context.after
// index the new user, send a welcome email, etc.
}

Entity events support the same hierarchical pattern matching as query events:

PatternMatches
entity.User.createdA specific model and verb
entity.UserAll mutations on the User model
entity.updatedEvery model that is updated
entityEvery entity mutation across all models
@On('entity.User')
async onAnyUserMutation(context: EventContext<'entity.User'>) {
// context.action is 'created', 'updated', or 'deleted'
// before / after are populated according to the action
}

Database events follow the same blocking rules as core events:

  • before.* events are always blocking. The database operation waits for all handlers to complete before proceeding.
  • after.* events are non-blocking by default. Handlers run in the background via waitUntil.
  • entity.* events are blocking by default, the same as custom events.

This means before handlers can validate or modify data before it reaches the database, while after handlers run without delaying the response. As with core events, you can override any default with the blocking option on @On().

The framework automatically augments the core CustomEventRegistry with both your query events and your entity mutation events, based on the StratalDatabase schema augmentation. Once you’ve set up database type augmentation, every database event name autocompletes and its context is fully typed. For entity events, before and after resolve to the full model type.

  • Events for the core event system.
  • Database for database configuration.
  • Auth for authentication integration.