Skip to content

Soft Deletes

Joist has built-in support for the soft-delete pattern, of marking rows with a deleted_at column and then “mostly ignoring them” within the application.

In our experience, it’s common to have application bugs where business logic “forgets to ignore soft-deleted rows”, so Joist flips the model to where soft-deleted rows are ignored by default, and business logic needs to explicitly opt-in to seeing them.

To use Joist’s soft-delete support, just add deleted_at columns to any entity you want to soft-delete.

By default, Joist will pick up any column named deleted_at or deletedAt as a soft-delete column, and use it for implicit filtering.

If you want to change the name of the deleted_at column, you can configure that in joist-config.json’s timestampFields key:

{
"timestampFields": {
"deletedAt": {
"names": ["deleted_at"]
}
}
}

Note that currently Joist assumes that deleted_at columns are timestamps, but they should work as boolean columns as well.

When entities are soft-deleted, Joist’s populate methods will still fetch their rows from the database, but collection accessors (i.e. o2m.get and m2m.get) will filter them out of the results.

For example, if an Author has a soft-deleted Book:

// This loads all books for a:1 from the db
const a = await em.load(Author, "a:1", "books");
// This list will not include any soft-deletes books
console.log(a.books.get);

If you do want to explicitly access soft-deleted rows, you can use the getWithDeleted accessor:

// This list will be everything
console.log(a.books.getWithDeleted);

em.find queries also filter out soft-deleted rows by default but at the database level (by adding a WHERE deleted_at IS NULL to the query).

If you’d like to include soft-deleted rows in a find query, you can use the softDeletes option:

const allBooks = await em.find(Book, {}, { softDeletes: "include" });

When Joist is about to create a new entity through em.upsert or em.findOrCreate, it will first check whether a matching soft-deleted row already exists. If it finds one, Joist will resurrect the row by clearing its deleted_at value and then apply the requested updates.

For example, if an Author with ssn = "123" was previously soft-deleted, this will update that existing row instead of inserting a duplicate:

const author = await em.upsert(Author, { ssn: "123", firstName: "Jane" });

Resurrection is treated as an update, not a create. The entity keeps its existing id, beforeUpdate hooks run, and beforeCreate hooks do not run.

For findOrCreate, Joist uses the provided where clause as the identity:

const author = await em.findOrCreate(Author, { ssn: "123" }, { firstName: "Jane" });

For em.upsert, Joist needs to know which fields identify an existing row. Joist infers single-column unique primitive fields, and you can also configure identities explicitly with uniqueBy in joist-config.json:

{
"entities": {
"Author": {
"uniqueBy": "ssn"
},
"Book": {
"uniqueBy": ["author", "title"]
}
}
}

uniqueBy accepts these equivalent forms:

{
"uniqueBy": "ssn"
}
{
"uniqueBy": ["author", "title"]
}
{
"uniqueBy": [["email"], ["author", "title"]]
}

Each identity must use fields that can be queried directly, such as primitives, enums, many-to-one references, and polymorphic references. Joist skips resurrection lookup when any identity value is null or undefined, and id: null in an upsert still means “force create a new row”.