Dirty Tracking
ActiveDrizzle tracks every attribute change from the moment a record is loaded or created. You can inspect what changed, what the previous value was, and whether anything changed at all.
Setup
ts
// schema.ts
export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: text('name').notNull(),
priceCents: integer('price_cents').notNull(),
status: integer('status').notNull().default(0),
})ts
// models/Product.model.ts
@model('products')
export class Product extends ApplicationRecord {
static status = Attr.enum({ draft: 0, published: 1, archived: 2 } as const)
}isChanged() / hasChanges()
ts
const product = await Product.find(1)
product.isChanged() // → false
product.name = 'New Name'
product.isChanged() // → truechangedAttributes()
Returns an object of { field: [previous, current] } pairs:
ts
product.name = 'New Name'
product.priceCents = 2999
product.changedAttributes()
// → { name: ['Old Name', 'New Name'], priceCents: [1999, 2999] }<field>Changed() — per-field dirty check
Every column gets a corresponding <field>Changed() method:
ts
product.nameChanged() // → true
product.priceChanged() // → false (priceCents changed, but priceChanged checks priceCents)
product.statusChanged() // → falseFor Attr.enum fields, the check is based on the label (string):
ts
product.status = 'published'
product.statusChanged() // → true
product.statusWas() // → 'draft' (previous label)<field>Was() — previous value
ts
product.name = 'New Name'
product.nameWas() // → 'Old Name' (value before this change)previousChanges() — what changed in the last save
After calling save(), the _previousChanges captures what was just changed:
ts
product.name = 'New Name'
await product.save()
product.isChanged() // → false (changes cleared)
product.previousChanges() // → { name: ['Old Name', 'New Name'] }
product.nameChanged() // → false (no current unsaved change)Useful in @afterSave hooks to know what just happened:
ts
@afterSave()
handleStatusChange() {
const changes = this.previousChanges()
if ('status' in changes) {
const [from, to] = changes.status
AuditLog.create({ model: 'Product', field: 'status', from, to })
}
}wasChanged(field) — check previous save
ts
product.name = 'New Name'
await product.save()
product.wasChanged('name') // → true (name changed in last save)
product.wasChanged('price') // → falseDirty tracking in hooks
ts
@beforeSave('priceChanged') // only runs if priceCents changed
async notifyPriceChange() {
await PriceAlert.trigger({
productId: this.id,
oldPrice: this.priceWas(),
newPrice: this.priceCents,
})
}
@afterUpdate('statusChanged')
async logStatusTransition() {
const { status: [from, to] } = this.previousChanges()
await AuditLog.create({ entity: 'Product', id: this.id, from, to })
}New records and dirty tracking
For new records, <field>Was() returns undefined (there is no previous value):
ts
const p = new Product({ name: 'Widget' })
p.nameChanged() // → true (it was set from undefined)
p.nameWas() // → undefinedAfter save() on a new record:
ts
await p.save()
p.isNewRecord // → false
p.isChanged() // → false
p.previousChanges() // → { name: [undefined, 'Widget'], ... }