Skip to main content

lifecycle hooks

BeforeCreate​

BeforeCreate hooks are run whenever a new record is being saved to the database. A decorator pattern is leveraged, so no arguments need to be passed to the hook, it simply needs to live above the property, like so:

// src/app/models/User.ts
import argon2 from 'argon2'

export default class User extends ApplicationModel {
@deco.BeforeCreate()
public async hashPassword() {
if (this.password) this.passwordDigest = await argon2.hash(this.password)
this.password = undefined
}
}

BeforeUpdate​

BeforeUpdate hooks are run whenever an existing (meaning, previously-saved) record is updated, and can be leveraged like so:

// models/Post.ts
export default class Post extends ApplicationModel {
...

@deco.BeforeUpdate()
public async doSomething() {
// ... do whatever you want here!
}

@deco.BeforeUpdate({ ifChanging: ['color'] })
public async doSomethingWhenColorIsChanging() {
// ... do whatever you want here!
}
}

BeforeSave​

BeforeSave hooks are run whenever either an existing OR new record is written to the DB, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.BeforeSave()
public async doSomething() {
// ... do whatever you want here!
}
}

changing the same record in a hook​

If a hook needs to change attributes on the record being persisted, use a before hook and assign the values directly. Do not call save() or update() on this from inside one of its own lifecycle hooks; that starts another persistence cycle while the current one is still running.

export default class Place extends ApplicationModel {
@deco.BeforeSave()
public normalizeAddress() {
if (this.city) this.city = this.city.trim()
}

// Avoid this pattern. Use a before hook and direct assignment instead.
@deco.AfterSave()
public async normalizeAddressAgain() {
if (this.city) await this.update({ city: this.city.trim() })
}
}

Use after hooks for side effects that require the row to already exist, such as creating associated records or delegating to a service. If the side effect writes other records, pass the hook's transaction through. If the side effect queues background work, use a commit hook variant.

before destroy​

BeforeDestroy hooks are run whenever either an existing record is destroyed from the DB, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.BeforeDestroy()
public async doSomething() {
// ... do whatever you want here!
}
}

after create​

AfterCreate hooks are run after a new record is saved to the database. A decorator pattern is leveraged, so no arguments need to be passed to the hook, it simply needs to live above the property, like so:

// src/app/models/Post.ts
export default class Post extends ApplicationModel {
...

@deco.AfterCreate()
public async createDefaultComment(this: Post, txn: DreamTransaction<Dream> | null) {
await this.txn(txn).createAssociation('comments', { body: 'Welcome!' })
}
}

Changing the record itself belongs in a before hook; by the time an after hook runs, the row is already written.

after update​

AfterUpdate hooks are run whenever an existing (meaning, previously-saved) record has been updated, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterUpdate()
public async doSomething() {
// ... do whatever you want here!
}

@deco.AfterUpdate({ ifChanged: ['color'] })
public async doSomethingWhenColorChanged() {
// ... do whatever you want here!
}
}

after save​

AfterSave hooks are run whenever either an existing OR new record has been written to the DB, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterSave()
public async doSomething() {
// ... do whatever you want here!
}
}

after destroy​

AfterDestroy hooks are run whenever either an existing record has been destroyed from the DB, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterDestroy()
public async doSomething() {
// ... do whatever you want here!
}
}

Hook signature and the transaction argument​

Non-commit hooks (AfterCreate, AfterUpdate, AfterSave, AfterDestroy, and their Before* counterparts) receive the current transaction as a single argument. Type it as DreamTransaction<Dream> | null (imported from @rvoh/dream), and pass it through to any service the hook delegates to so all of that service's writes participate in the same transaction:

import { Dream, DreamTransaction } from '@rvoh/dream'

export default class Post extends ApplicationModel {
@deco.AfterCreate()
public async seedDefaults(this: Post, txn: DreamTransaction<Dream> | null) {
await SeedDefaultComments.seed(this, txn)
}
}
// app/services/SeedDefaultComments.ts
export default class SeedDefaultComments {
public static async seed(post: Post, txn: DreamTransaction<Dream> | null = null) {
await Comment.txn(txn).create({ post, body: 'Welcome!' })
}
}

A create or update hook's txn is null unless the write runs inside a transaction — one the caller started with ApplicationModel.transaction(...), or the per-batch transaction of a { lock: true } query update. Dream opens no transaction around Model.create() or an instance update() for its hooks. Model.txn(null).create(...) is a no-op variant, so the same service can be called from a hook (with txn possibly null) or from a controller (with null) using one code path.

An after hook that writes an association of the record binds the write the same way:

@deco.AfterCreate()
public async createDefaults(this: Host, txn: DreamTransaction<Dream> | null) {
await this.txn(txn).createAssociation('profile', {})
}

Commit hooks (below) do not receive a txn argument, because the transaction has already committed by the time they run.

Gating hooks on a change​

ifChanging (on BeforeCreate, BeforeUpdate, and BeforeSave) runs the hook only when the save is changing one of the listed columns; ifChanged (on AfterCreate, AfterUpdate, AfterSave, and their commit variants) runs it only when the save changed one. On a create, both count only the columns that were assigned, not the database defaults and nulls the insert returns. See dirty tracking in hooks for reading the change itself inside a hook.

Gating on an encrypted field. When the gated field is an @deco.Encrypted field, list the persisted column name encrypted<Name> in ifChanged/ifChanging (e.g. ifChanged: ['encryptedPhone']), not the plaintext virtual (phone) — change detection runs over the real persisted columns, and changing the virtual marks the underlying encrypted<Name> column dirty.

Hook order around a dependent: 'destroy' cascade​

A destroy runs the record's own BeforeDestroy hooks first, then the dependent: 'destroy' cascade, then AfterDestroy — so a BeforeDestroy on Place still sees its Room records present, and an AfterDestroy sees them gone. Work that must read, count, or archive the children belongs in BeforeDestroy; work that assumes they're already deleted belongs in AfterDestroy, which still runs inside the destroy's transaction.

undestroy() (on a @SoftDelete() model) goes the other way: the deepest descendants are restored first, and the record itself last. A child's AfterUpdate therefore runs while its parent still carries a non-null deletedAt, so an association read back to the parent through the parent's own default scopes finds nothing. Read it with removeDefaultScope('dream:SoftDelete'), or move the work to the parent's AfterUpdate, which runs after the whole cascade.

after commit hooks​

In contrast to ordinary after hooks (like AfterCreate, AfterUpdate, etc...), we have commit variants. The implementation is similar to the approach taken by Ruby on Rails, whereby commit hooks are bound to an existing transaction if it exists, and will wait until after the commit has been made at the transaction level to run your callback.

IMPORTANT: any lifecycle hook that queues background work must use a commit hook, whether it calls a backgrounded service, calls this.background(...) on a backgrounded model, or delegates to another method that queues the job. Regular hooks run before an enclosing transaction commits, so a job queued from one can run before the commit and either miss a newly-created row or read stale data after an update. A commit hook ensures the row exists after create and the latest persisted data are visible after update by the time the worker loads the record.

A commit hook that throws rejects its writer after the write has committed. The create, update or destroy — or the enclosing ApplicationModel.transaction(...) — rejects, the code after it and any later commit hooks are skipped, and nothing rolls back. A failed background(...) call in a Booking @deco.AfterUpdateCommit leaves the booking confirmed with nothing enqueued. For work that must not be lost, see work that must not be lost after a commit.

after create commit​

AfterCreateCommit hooks are run after a new record is saved to the database and the transaction is committed.

export default class Post extends ApplicationModel {
...

@deco.AfterCreateCommit()
public async yourThing() {
// ... your code
}
}

after update commit​

AfterUpdateCommit hooks are run whenever an existing (meaning, previously-saved) record has been updated and the encapsulating transaction has been committed, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterUpdateCommit()
public async doSomething() {
// ... do whatever you want here!
}

@deco.AfterUpdateCommit({ ifChanged: ['color'] })
public async doSomethingWhenColorChanged() {
// ... do whatever you want here!
}
}

after save commit​

AfterSaveCommit hooks are run whenever either an existing OR new record has been written to the DB and the encapsulating transaction has been committed, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterSaveCommit()
public async doSomething() {
// ... do whatever you want here!
}
}

after destroy commit​

AfterDestroyCommit hooks are run whenever an existing record has been destroyed from the DB and the transaction has been committed, and can be leveraged like so:

export default class Post extends ApplicationModel {
...

@deco.AfterDestroyCommit()
public async doSomething() {
// ... do whatever you want here!
}
}