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
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.tsscripts/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:
findManyfindFirstfindFirstOrThrowfindUniquefindUniqueOrThrowcountaggregategroupBy
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.
// `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.tslooks up anexternalPaymentId, where the payment id itself proves the payment was processed.lib/privacy/anonymize.tsserver/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:
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 anywhereThe 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
functionsIt 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-okUse 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-deleteThis 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.
Related
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.
Guarantees
Learn which money, refund, stock, identity, visibility and cache promises Postgres enforces, which ones a harness check enforces, and which ones are explicitly not guaranteed.
Harness
Learn about the 25 CI checks that run on your fork, and the 8-guide rule set behind them.