Litestore · Developer docs
Database

Deletion

Write queries against archivable models so archived rows never show up in a read by accident.

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.

Order #1042Paid $120.00
Refunded $80.00Requested $60.00
Write rejectedTotal refunded can't exceed the order total. Nothing was saved.
The shared client hides archived rows from top-level reads, so a forgotten filter can't leak them.

Archiving

A model with a deletedAt column is archivable. Setting that timestamp hides the row instead of removing it. Because archived rows stay in the table, every read in the application has to exclude them, and a single read that doesn't could show a deleted product in a storefront listing.

To handle this, platform/db/soft-delete-extension.ts adds deletedAt: null to top-level reads for those models, and platform/db/client.ts attaches the extension when it constructs the client. The extension can't intercept nested relation reads, so you filter those yourself.

Extension setup

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

Every module that imports db from ~/platform/db/client gets the filtered client. prisma/seed.ts builds its own PrismaClient, so Litestore doesn't filter its reads.

seed.ts

The list of archivable models is derived from the schema rather than maintained by hand. In the extension, MODELS_WITH_SOFT_DELETE picks every model in Prisma.dmmf.datamodel.models that has a field named deletedAt, and platform/db/soft-delete-models.ts exports the same result as SOFT_DELETE_MODELS.

When you add deletedAt to a model, the extension picks it up automatically, with no registration step.

Intercepted operations

The extension intercepts eight read operations on $allModels: findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, count, aggregate and 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, so the call keeps its exact return type and doesn't need to be rewritten as findFirst. A soft-deleted row is returned as not found.

Unfiltered operations

The extension doesn't filter three kinds of query.

Query kindWhy the extension skips it
Nested relation readsPrisma's query hooks only receive the top-level operation. The extension can't read a where inside an include or select, so you write deletedAt: null on nested relations yourself.
WritesThe extension doesn't touch update, updateMany, upsert, delete or deleteMany. A filter on a write would change how conditional compare-and-set claims behave, and it would break archive restore, which needs to target soft-deleted rows.
Raw SQLThe extension doesn't touch $queryRaw or the other raw query methods.

The payment webhook's conditional order claim is an example of a write that a filter would change. It's an updateMany on id that also filters on financialStatus: { notIn: settled } and deletedAt: null.

platform/utils/slug.ts checks slug and SKU uniqueness across deleted rows as well, because the database unique constraints ignore deletedAt. An archived row keeps its slug, so no new row can use it.

Opt-out

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

It looks inside the AND, OR and NOT combinators but not inside relation filters. It checks for the key with in rather than checking whether the value is truthy, so writing deletedAt: undefined explicitly counts as naming it. Prisma ignores the undefined value, but the extension still reads the key, and the read returns both deleted and live rows.

modules/inventory/location-stock.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 },
})

The repository has a handful of other opt-outs. modules/orders/payments/payment-webhook.ts opts out when it looks up an externalPaymentId, since the payment id itself shows the payment was processed. modules/customers/anonymize.ts, modules/customers/referral-links.ts, modules/reviews/review-archive.ts, modules/operations/activation-rules.ts and the collab and coupon CRUD modules also opt out.

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:

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

Nested reads

The extension only reaches top-level reads. If you write a nested include or select of a soft-deletable relation without its own where, the read returns archived rows along with live ones. That leads to wrong counts, deleted data showing up in the UI and skewed averages, and tsc doesn't report anything.

top-level read
    ↓
extension injects deletedAt: null

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

Nothing in verify checks nested reads, so you add the filter yourself whenever you include a to-many relation whose target model has deletedAt.

Filters by read type

ReadFilter you write
Top-level findMany, findFirst, findUnique, count, aggregate or groupBy on the shared dbYou write nothing. The extension already filters it, and adding deletedAt: null by hand changes nothing.
Nested include or select of a to-many soft-deletable relationYou write where: { deletedAt: null }. If the relation points at a join model, you filter through the join model instead.
A read that has to return archived rowsYou write deletedAt: undefined and explain the reason in a comment.
A read that should return only archived rowsYou use archivedWhere instead of a hand-written { not: null }.
A nested to-one relationYou check it yourself, because the extension doesn't reach it and Prisma doesn't allow a nested where.

Each opt-out in the repository has a comment explaining why it's needed, and yours should too.

In general, Litestore adds the filter automatically for top-level reads. Nested reads, to-one relations, raw SQL and writes are the exceptions, and you need to handle those cases yourself.

On this page