Skip to main content

destroying

To destroy a model, simply call the destroy method on the instance:

import User from 'app/models/user'

const user = await User.first()
await user.destroy()

destroyAssociation​

Associations can be destroyed using destroyAssociation:

const place = await Place.first()
await place.destroyAssociation('rooms')
// OR
await place.destroyAssociation('rooms', { and: { name: 'my room' } })

Cascading​

Both destroy() and undestroy() (see below) cascade through associations declared dependent: 'destroy'. If each associated model is decorated with @SoftDelete (see below), then the models corresponding to that association will be soft deleted:

const deco = new Decorators<typeof Place>()

export default class Place extends ApplicationModel {
@deco.HasMany('Room', { dependent: 'destroy' })
public rooms: Room[]
}
await Room.count()
// 3
const place = await Place.first()
await place.associationQuery('rooms').count()
// 3
await place.destroy()
await Room.count()
// 0

Every model in a dependent: 'destroy' chain must also carry @SoftDelete(), or those associated records are permanently deleted when the parent is destroyed. Dream does not check this for you — if a dependent: 'destroy' association points to a model without @SoftDelete, calling destroy() on the parent irreversibly deletes those associated records from the database rather than soft-deleting them.

SoftDelete​

The @SoftDelete decorator enables a model to be hidden, by default, from all queries, without actually being deleted from the database. This is useful for providing an "undo" deletion, or a "trash can" that retains records for a period before permanent removal, or for retaining records for auditing, analytics, or compliance while removing them from the application's active data.

Don't hand-roll a deactivate/delete mechanism. When you need "removed but recoverable / auditable" semantics, that's exactly what @SoftDelete() provides. A custom removed / isDeleted / deactivatedAt column is almost always redundant and fights the lifecycle — it won't be honored by destroy() / undestroy(), the dream:SoftDelete default scope, or dependent: 'destroy' cascades. Reach for a domain status flag only when it means something other than deletion — e.g. an active flag meaning "currently bookable" on a row that's still live and queryable. If the flag's real meaning is "this record is gone," delete the flag and use @SoftDelete instead.

The g:resource and g:model generators include @SoftDelete() and a deleted_at column automatically. The setup below is only needed when adding soft delete to an existing model that was generated with --no-soft-delete, or to a model that predates the decorator.

Setup​

First create a migration to add a deleted_at column to the database:

pnpm psy g:migration add-deleted-at-to-rooms deleted_at:datetime:optional

Then apply the @SoftDelete decorator to the Dream model class, and run pnpm psy sync — this regenerates src/types/dream.ts with the model's default scopes, which is where removeDefaultScope('dream:SoftDelete') gets its type:

@SoftDelete()
export default class Place extends ApplicationModel {
...
}

STI children never receive @SoftDelete(). It belongs on the STI parent only, and every child inherits it, layering the inherited dream:SoftDelete scope alongside the child's own dream:STI default scope. See single table inheritance for STI generation and model patterns.

Foreign key constraints​

When using soft deletion, use the default foreign key deletion constraint of 'restrict', not 'cascade'. Since the parent row is never actually deleted, a cascade constraint never fires — restrict is what correctly prevents deletion of rows that still have references:

await db.schema
.createTable('rooms')
.addColumn('id', 'bigserial', col => col.primaryKey())
.addColumn('place_id', 'bigint', col => col.references('places.id').onDelete('restrict').notNull())
.execute()

Natural-key unique indexes​

If you add @SoftDelete() to a model with a unique index on a natural key — a slug, a code, a (host_id, name) pair, anything other than the primary key — migrate that index to a partial unique index with WHERE deleted_at IS NULL. Soft-deleted rows stay in the table, so a plain unique index keeps reserving the natural key after deletion, blocking a new live row from reusing it:

import { sql, type SqlBool } from 'kysely'

await db.schema.dropIndex('places_slug_unique').execute()

await db.schema
.createIndex('places_slug_unique')
.unique()
.on('places')
.column('slug')
.where(sql<SqlBool>`deleted_at IS NULL`)
.execute()

Without the predicate, soft-deleting a Place with slug: 'cozy-cabin' and then creating a new one with the same slug fails on the unique constraint, even though no live row holds it. One consequence to expect: undestroy() can then fail if a live row claimed the natural key while the original row was soft-deleted — restoring would create two live rows sharing that key, which the partial index correctly rejects.

Sortable position columns​

If you add @SoftDelete() to a model that also has @deco.Sortable(), make the position column nullable. Soft-deleting a record sets every @Sortable field's position column to null in the same update as deletedAt, clearing the record's slot in its sortable scope — a NOT NULL position column throws a not-null violation, including when the sortable model is only a dependent: 'destroy' cascade target of a parent being destroyed, not just on a direct destroy() call.

undestroy() sets the position column back to MAX(position) + 1 within its sortable scope, in the same update that clears deletedAt — the record is re-appended to the end of its sortable scope, not restored to its original position. If you need the original ordering back, re-position the record explicitly after undestroying.

Setting deletedAt yourself is not a substitute for destroy()​

A raw SQL UPDATE, a data-repair migration, or Place.where({ id }).update({ deletedAt: DateTime.now() }) really does soft-delete the row: the dream:SoftDelete scope hides it from then on, and a query-level update still runs the model's update hooks, so the write reads as though it worked. What it skips is the rest of the destroy lifecycle — no beforeDestroy/afterDestroy, and no dependent: 'destroy' cascade, so dependent rows are left live pointing at a parent nothing returns.

Loading soft deleted models​

Soft deleted models can be included in queries by removing the soft delete scope by name. removeDefaultScope('dream:SoftDelete') lifts that scope and nothing else, whether you're querying for one record or many — see removing default scopes:

await Place.create({ name: 'My Place' })
await Place.count()
// 1
await Place.destroy()
await Place.count()
// 0
await Place.removeDefaultScope('dream:SoftDelete').count()
// 1

Restoring soft deleted models (undestroy)​

Soft-deleted records can be restored with undestroy(), which sets deletedAt back to null. Because the record is hidden by the dream:SoftDelete default scope, you must bypass it to find the record first:

const place = await Place.removeDefaultScope('dream:SoftDelete').findOrFail(id)
await place.undestroy()
// place.deletedAt is now null; the record is visible again in normal queries

undestroy() cascades through dependent: 'destroy' associations just like destroy() does, restoring associated soft-deleted records along with the parent:

await place.undestroy()
// restores the place AND its rooms and hostPlaces

Associations can be restored directly with undestroyAssociation:

await place.undestroyAssociation('rooms')

Query-level undestroy is also available, for restoring a plurality of records in one call:

await Place.removeDefaultScope('dream:SoftDelete').where({ id }).undestroy()

Deleting @SoftDelete models from the database​

A model marked @SoftDelete can be permanently deleted from the database with reallyDestroy:

await Place.create({ name: 'My Place' })
await Place.count()
// 1
await Place.reallyDestroy()
await Place.removeDefaultScope('dream:SoftDelete').count()
// 0

reallyDestroy() cascades through the record's dependent: 'destroy' associations, hard-deleting each one depth-first (children before the parent) instead of soft-deleting it. It bypasses the dream:SoftDelete default scope while loading that cascade, so children that were already soft-deleted are loaded and hard-deleted too. A restrict-FK child that isn't reachable through a dependent: 'destroy' association is never touched by the cascade — if such a row still references the parent, reallyDestroy() throws a foreign key violation instead of silently deleting it.

The query form selects under the model's default scopes, dream:SoftDelete among them — the bypass just described governs which children the cascade loads, not which records the query selects. So Place.where({ ... }).reallyDestroy() matches only live rows: a prune written over already-soft-deleted rows returns 0 and deletes nothing, with no error. Remove the scope to select them:

await Place.removeDefaultScope('dream:SoftDelete')
.where({ deletedAt: ops.lessThan(DateTime.now().minus({ days: 30 })) })
.reallyDestroy()

There is an analogous method for deleting associated models marked @SoftDelete from the database:

const place = await Place.first()
await place.reallyDestroyAssociation('rooms')
// OR
await place.reallyDestroyAssociation('rooms', { and: { name: 'my room' } })

The query forms of destroy() and reallyDestroy() (Place.where({ ... }).destroy(...)) accept { lock: true }, which makes the destroy a guarded (compare-and-set) removal. That's a concurrency concern rather than a soft-delete one; see the locking guide.