Litestore · Developer guide
Database

Deletion

This page explains how Litestore hides archived rows from reads automatically and what you have to filter yourself.

When you delete a product or another archivable record in Litestore, the row stays in the database and is only hidden. Read this page when you write a query against an archivable model or need to read archived rows on purpose.

A model with a deletedAt column is archivable. Setting that timestamp hides the row instead of removing it.

This approach creates a new risk. Nothing is destroyed, but every read in the application now has to exclude archived rows. If a single read forgets, a deleted product can show up in a storefront listing.

To prevent that, lib/soft-delete-extension.ts adds deletedAt: null to top-level reads for those models, so a forgotten filter can't leak archived rows. services/db.ts attaches the extension when it constructs the client.

The extension can't see nested relation reads. A separate check that reads the TypeScript AST makes sure you filter those yourself.

Where it is attached

services/db.ts
return new PrismaClient({
  adapter,
  transactionOptions: { maxWait: 15_000, timeout: 30_000 },
}).$extends(createSoftDeleteExtension())

Every module that imports db from ~/services/db gets the filtered client.

Two files build their own PrismaClient, so their reads are not filtered:

  • prisma/seed.ts
  • scripts/check-db-guarantees.ts

Nobody maintains a list of archivable models by hand. The code works it out from the schema.

In the extension, MODELS_WITH_SOFT_DELETE picks every model in Prisma.dmmf.datamodel.models that has a field named deletedAt. lib/soft-delete-models.ts exports the same result as SOFT_DELETE_MODELS for the linter.

When you add deletedAt to a model, the extension picks it up without any registration step. Since there is no list to update, you can't forget to update it.

What it intercepts

The extension intercepts eight read operations on $allModels:

  • findMany
  • findFirst
  • findFirstOrThrow
  • findUnique
  • findUniqueOrThrow
  • count
  • aggregate
  • groupBy

For findUnique and findUniqueOrThrow, the extension puts the filter directly into the unique where.

Prisma has allowed non-unique fields next to a unique filter since Prisma 5, when "extendedWhereUnique" became generally available. The call therefore keeps its exact return type and doesn't need to be rewritten as findFirst. A soft-deleted row simply comes back as not found.

What it deliberately does not intercept

The extension leaves three kinds of query alone, and each has a reason.

Nested relation reads

Prisma's query hooks only receive the top-level operation. The extension can't see a where inside an include or select.

You still have to write deletedAt: null filters on nested relations yourself.

Writes

The extension doesn't touch update, updateMany, upsert, delete or deleteMany.

Adding a filter to a write would change how conditional compare-and-set claims behave. One example is the payment webhook's conditional order claim. It is an updateMany on id that also filters on financialStatus: { notIn: settled } and deletedAt: null.

A write filter would also break archive restore, which needs to target soft-deleted rows.

Raw SQL

The extension doesn't touch $queryRaw or the other raw query methods.

lib/utils/slug.ts deliberately checks slug and SKU uniqueness across deleted rows too, because the database unique constraints ignore deletedAt. When you archive a row, its slug is still taken.

Opting out

The extension's mentionsDeletedAt function skips the filter when the top-level where already names deletedAt.

It only looks inside the AND, OR and NOT combinators and never inside relation filters. It checks for the key with in instead of checking whether the value is truthy, so writing deletedAt: undefined explicitly counts as naming it.

Prisma ignores the undefined value, but the extension still sees the key. The read then returns deleted and live rows together.

lib/inventory.ts
// `deletedAt: undefined` opts out of the soft-delete extension's injected
// filter so a deleted variant reports VARIANT_DELETED instead of the
// misleading VARIANT_NOT_FOUND.
const variant = await db.productVariant.findUnique({
  where: { id: variantId, deletedAt: undefined },
  select: { id: true, deletedAt: true, sku: true },
})

These are the other opt-outs in the repository:

  • lib/payments/payment-webhook.ts looks up an externalPaymentId, where the payment id itself proves the payment was processed.
  • lib/privacy/anonymize.ts
  • server/customers/referral-links.ts

The archived queue in the admin needs the opposite filter, so it asks for non-null deletedAt explicitly. Admin list queries spread one shared predicate:

lib/soft-delete-models.ts
export function archivedWhere(archived: string | undefined): { deletedAt?: { not: null } } {
  return archived === "true" ? { deletedAt: { not: null } } : {}
}

The nested-read check

scripts/check-nested-soft-delete.ts covers the reads that the extension can't reach.

Suppose you write a nested include or select of a soft-deletable relation without its own where. That read quietly returns archived rows, which gives you wrong counts, leaked deleted data and skewed averages. tsc doesn't complain about it.

top-level read
    ↓
extension injects deletedAt: null

nested include, no where
    ↓
nothing injected
    ↓
archived rows in the result
    ↓
no error anywhere

The script parses the TypeScript AST instead of scanning text. Regexes and template literals can't confuse it, and it reports exact positions. It only reads runtime object literals, so it skips type annotations such as _count?: { select: { products: true } }.

It checks the .ts and .tsx files in these folders:

app
components
lib
server
services
config
events
emails
hooks
functions

It ignores declaration files and tests.

It flags an include: or select: property when the property names a soft-deletable relation and its value is either true or an object without a qualifying where.

Three rules narrow what it flags

It only checks to-many relations. Prisma doesn't let you attach a where to a to-one relation read, and include: { product: { where: … } } is a type error. Because the usual fix isn't available there, the check only looks at list (X[]) relations whose target is a soft-delete model.

It only checks names it can resolve. The script reads relation names from prisma/schema.prisma each time it runs. Some names, such as media and items, are also used elsewhere for a to-one relation or for a child row that can't be archived. A scan by name can't tell those apart, so the script skips them instead of reporting false positives.

deletedAt anywhere in the where counts. Some relations point at a join model, such as Collection.products → CollectionProduct[]. The join model has no deletedAt of its own, so you filter through it with where: { product: { deletedAt: null } }. The check also accepts publishedProductWhere() from lib/products/visibility.ts, because that function is known to return deletedAt: null.

The escape hatch

The check skips any line that ends with this comment:

// soft-delete-ok

Use it for an archived view that is meant to include deleted rows. No line in the repository uses it yet.

Running it

bun run lint:nested-soft-delete

This runs scripts/check-nested-soft-delete.ts.

The check also runs inside bun run verify, so a nested read without a filter fails the gate. The output lists each problem as file:line → relation and shows you the fix.

A to-one leak is not reported

The check skips to-one relations because Prisma has no nested where you could fix them with. If you include a soft-deletable to-one relation, it can still return an archived row. To close that gap, filter at the top level or check deletedAt on the loaded record yourself.

Which filter a read needs

Most reads need nothing extra. A top-level findMany, findFirst, findUnique, count, aggregate or groupBy on the shared db is already filtered. Adding deletedAt: null by hand changes nothing.

A nested include or select of a to-many soft-deletable relation needs its own where: { deletedAt: null }. If the relation points at a join model, filter through the join model instead. The check will catch a missing filter, but writing it up front saves you a failed run.

A read that has to see archived rows uses deletedAt: undefined. That is a deliberate opt-out. Each of the four opt-outs in the repository has a reason, and you should explain yours in a comment.

A read that should see only archived rows uses archivedWhere instead of a hand-written { not: null }.

Neither the extension nor the check helps with a nested to-one relation, so you have to check that one yourself.

The whole setup is built around one question:

If someone forgets, does the archived row leak?

Where the answer would be yes, Litestore either adds the filter for you or fails the build. To-one relations, raw SQL and writes are the cases where neither is possible. For those three, you are responsible, and this page documents them so nobody has to assume.

On this page