Soft deletes
Keep deleted rows, marked with the time they were deleted, and leave them out of every listing.
Setup
The code of this recipe depends on these declarations: the imports, the schema classes, the repo, and the migration that creates the tables. Run the recipe in an empty database of its own: each recipe creates its own tables, and some have the same names (posts) with other columns.
import { postgres } from "@egon-orm/postgres";
import { cast, change, createRepo, fragment, from, type QueryWith, Schema, t } from "egon-orm";
import { createMigrator, defineMigration } from "egon-orm/migration";
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
// The tables, created by a migration.
const tables = defineMigration({
up(m) {
m.createTable("posts", (table) => {
table.id({ type: "integer" });
table.text("title").notNull();
table.timestamptz("deleted_at");
});
// A unique title among the posts that are not deleted: a partial unique index, named
// posts_title_key, the name uniqueConstraint("title") looks for.
m.createIndex("posts", ["title"], { unique: true, where: fragment`deleted_at is null` });
},
down(m) {
m.dropTable("posts");
},
});
/** Creates the tables, in the database that DATABASE_URL names. */
export async function createTables(): Promise<void> {
const admin = postgres({ connectionString: process.env.DATABASE_URL });
await createMigrator({
adapter: admin,
migrations: [{ version: 20261021001001, name: "soft_deletes_tables", migration: tables }],
}).up();
await admin.close();
}The field
class Post extends Schema("posts", {
id: t.integer().primaryKey().dbGenerated(),
title: t.string(),
deletedAt: t.timestamp().nullable(),
}) {
// A second live post with the same title is a field error, not an exception.
static changeset(post: Post | typeof Post, params: unknown) {
return cast(post, params, ["title"]).validateRequired(["title"]).uniqueConstraint("title");
}
get isDeleted(): boolean {
return this.deletedAt !== null;
}
}The table's unique index on title is partial (where deleted_at is null, in the migration above) and named posts_title_key, the name uniqueConstraint("title") looks for: a title is unique among the live posts only.
Leaving deleted rows out
A scope is a function from query to query, applied with .pipe(…). QueryWith<{ p: typeof Post }> is the type of any query that has a binding p of Post, so the scope works on every such query and returns the same query type (Reusable pieces). Every listing starts from a base query that applies it:
// A reusable scope: any query with a binding `p` of Post.
const live = <Q extends QueryWith<{ p: typeof Post }>>(q: Q): Q => q.where(({ p }) => p.deletedAt.isNull());
export const posts = from(Post, "p").pipe(live); // the base query every listing starts fromThere is no default scope that applies by itself: repo.all(Post) and repo.get(Post, id) still see deleted rows. That is deliberate (what a query does is written in the query), and it is what an admin page or a restore needs. Start listings from posts, or from from(Post, "p").pipe(live).
Deleting, restoring, purging
export function softDelete(post: Post) {
return repo.updateOrThrow(change(post, { deletedAt: new Date() }));
}
// Restoring can meet the same index: a live post may have taken the title meanwhile. The
// result reports it on the field, { title: ["has already been taken"] }.
export function restore(post: Post) {
return repo.update(change(post, { deletedAt: null }).uniqueConstraint("title"));
}
// Remove for good what was deleted more than 30 days ago.
export function purge(before = new Date(Date.now() - 30 * 86_400_000)) {
return repo.deleteAll(from(Post, "p").where(({ p }) => p.deletedAt.lt(before)));
}A soft delete is an update, so it goes through a changeset, sets updatedAt if the schema has timestamps, and can carry an optimistic lock.
The example below starts from these rows, after creating the tables:
await createTables();
await repo.insertAll(Post, [{ title: "One" }, { title: "Two" }]);It deletes a post, which leaves the listing but stays in the table. Both the changeset and restore declare the unique index, so a clash is a field error: a deleted post's title is free for a new post, and restoring the old one then meets the index.
const deleted = await softDelete(await repo.getByOrThrow(Post, { title: "One" }));
deleted.isDeleted; // => true
// The listing leaves it out; the table still has it.
(await repo.all(posts)).map((p) => p.title); // => ["Two"]
await repo.count(Post); // => 2
// While "One" is deleted, its title is free for a new post...
const again = await repo.insert(Post.changeset(Post, { title: "One" }));
again.success; // => true
// ...so the deleted one cannot come back under it.
const back = await restore(deleted);
back.success ? null : back.error.errorMessages(); // => { title: ["has already been taken"] }
const twin = await repo.insert(Post.changeset(Post, { title: "Two" })); // "Two" is live
twin.success ? null : twin.error.errorMessages(); // => { title: ["has already been taken"] }Good to know
- Unique values. A unique index would count deleted rows too: make it partial, as the migration does. With another name than
posts_title_key, pass it:uniqueConstraint("title", { name }). - Associations. A preload of
postsloads deleted posts too. Filter in the callback form:preload("posts", (q) => q.where(({ posts }) => posts.deletedAt.isNull())), or give the association awhere. - Foreign keys still point at a soft-deleted row;
ON DELETEdoes nothing until it is purged.

