Soft Deletes

Mark records as deleted without permanently removing them from the database

Enabling Soft Deletes

Add a deletedAt column with softDelete: true:

models/Post.ts
import { defineModel, DataTypes } from "stabilize-orm";
export const Post = defineModel({
tableName: "posts",
columns: {
id: { type: DataTypes.STRING, required: true, unique: true },
title: { type: DataTypes.STRING, length: 255, required: true },
content: { type: DataTypes.TEXT },
deletedAt: { type: DataTypes.DATETIME, softDelete: true },
},
});

Soft Deleting Records

The delete() method sets the deletedAt timestamp:

typescript
const repo = orm.getRepository(Post);
// Soft delete (sets deletedAt to current timestamp)
await repo.delete(post.id);
// The record still exists but won't appear in normal queries
const allPosts = await repo.find().execute(orm.client);
// Soft-deleted records are automatically excluded

Finding Deleted Records

typescript
// Find only soft-deleted records
const deletedPosts = await repo.findDeleted().execute(orm.client);
// Find all records including soft-deleted
const allPosts = await repo.withTrashed().execute(orm.client);
// Count them - there is no countDeleted(). Count the query instead:
const deletedCount = await repo.findDeleted().countExec(orm.client);

There is no countDeleted(). findDeleted() returns a builder, so any of the builder's aggregate terminators will do — countExec() or existsExec().

findDeleted() throws without a soft-delete column

findDeleted() checks for a soft-delete column and raises QUERY_ERROR if the model has none. withTrashed() does not check — it returns a builder with no filter attached, which on such a model is the same result an ordinary query already gives. See Helper Methods.

Recovering Records

typescript
// Recover a single record
const recovered = await repo.recover(post.id);
// Recover all soft-deleted records
const recoveredCount = await repo.recoverAll();
console.log("Recovered", recoveredCount, "records");

Bulk Soft Delete

typescript
// Bulk soft delete multiple records. Returns void, and runs in one
// transaction; batchSize defaults to 1000.
await repo.bulkDelete([post1.id, post2.id, post3.id]);
// Conditional delete - returns the affected row count
const deletedCount = await repo.deleteBy({ status: "archived" });

How It Works

  • find() automatically adds WHERE deletedAt IS NULL, and every method built on it inherits that filter
  • delete() runs UPDATE SET deletedAt = ? with the current time bound as a parameter, instead of DELETE. The timestamp is formatted for the dialect rather than written as NOW() — SQLite has no NOW() function
  • recover() runs UPDATE SET deletedAt = NULL
  • count() and exists() build their own query but still exclude soft-deleted rows, as does paginate()
  • Lifecycle hooks (beforeDelete, afterDelete) still fire on soft delete, and delete() throws DELETE_ERROR if the row does not exist

delete() runs inside a transaction: it reads the row first so the hooks receive the pre-delete entity, then deletes, then fires afterDelete. That read is also why a missing id is an error rather than a silent no-op.