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.
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
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.
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 kind | Why the extension skips it |
|---|---|
| Nested relation reads | Prisma'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. |
| Writes | The 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 SQL | The 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.
// `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:
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 anywhereNothing 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
| Read | Filter you write |
|---|---|
Top-level findMany, findFirst, findUnique, count, aggregate or groupBy on the shared db | You 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 relation | You 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 rows | You write deletedAt: undefined and explain the reason in a comment. |
| A read that should return only archived rows | You use archivedWhere instead of a hand-written { not: null }. |
| A nested to-one relation | You 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.