Soft Deletes
Mark records as deleted without permanently removing them from the database
Enabling Soft Deletes
Add a deletedAt column with softDelete: true:
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:
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 queriesconst allPosts = await repo.find().execute(orm.client);// Soft-deleted records are automatically excludedFinding Deleted Records
// Find only soft-deleted recordsconst deletedPosts = await repo.findDeleted().execute(orm.client);
// Find all records including soft-deletedconst 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
// Recover a single recordconst recovered = await repo.recover(post.id);
// Recover all soft-deleted recordsconst recoveredCount = await repo.recoverAll();console.log("Recovered", recoveredCount, "records");Bulk Soft Delete
// 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 countconst deletedCount = await repo.deleteBy({ status: "archived" });How It Works
find()automatically addsWHERE deletedAt IS NULL, and every method built on it inherits that filterdelete()runsUPDATE SET deletedAt = ?with the current time bound as a parameter, instead ofDELETE. The timestamp is formatted for the dialect rather than written asNOW()— SQLite has noNOW()functionrecover()runsUPDATE SET deletedAt = NULLcount()andexists()build their own query but still exclude soft-deleted rows, as doespaginate()- Lifecycle hooks (
beforeDelete,afterDelete) still fire on soft delete, anddelete()throwsDELETE_ERRORif 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.