Soft delete and tenant scoping in Prisma, written once instead of in every query
Every team that soft-deletes rows ends up with a deletedAt filter in a hundred places, and the one that gets forgotten is usually inside an include. Tenant filters fail the same way, only worse. Here is how to move both below your queries with @imqueue/pg-prisma on Prisma Next, so a delete becomes a stamp and every read, relations included, sees only live rows the caller may see. Every behaviour is measured on a real Postgres.
Soft delete sounds like a one-line decision. Instead of removing a row, you set
deletedAt and keep it. Support can bring it back, the audit trail stays
intact, and nothing downstream breaks on a missing foreign key.
Then the bill arrives, spread across the codebase. Every read now needs
deletedAt IS NULL. Every list, every lookup by id, every count on a dashboard.
Somebody adds a new endpoint and forgets it once, and a customer sees a task they
deleted last week. The forgotten filter is rarely in the obvious place. It is
inside an include, where the related rows are loaded in the same query and
nobody thought to filter them.
Multi-tenant scoping has the same shape and a worse failure. Forget
workspaceId = ? once, and one customer reads another customer's data.
The fix for both is the same: stop writing the filter in queries and put it
underneath them, in one place that every statement passes through. This post
shows how to do that on Prisma Next (Prisma 8) with
@imqueue/pg-prisma, and what each piece does in the
SQL that actually reaches Postgres.
Everything below was measured with @imqueue/pg-prisma 2.3.0,
@prisma/orm-postgres 8.0.0-rc.11 and PostgreSQL 17, by running each query and
reading the statement Postgres received. Where something surprised us, it says
so.
The short version
- Add
deletedAt,updatedAt,createdBy,updatedByanddeletedByto a model and it is soft-deleted and stamped. No list to maintain: the package reads your emittedcontract.json. - One
dataLayer()call gives you a middleware array forpostgres(). After that,.delete()writes a timestamp, and every read skips deleted rows, including rows loaded throughinclude. - Tenant scoping is declared per model, with levels: columns inside a level are OR-ed, levels are AND-ed. Reads, updates and deletes are narrowed. Inserts are not.
- A resolver that returns
undefinedswitches its level off. Returnnullwhen there is no request, so a missing context shows nothing instead of everything. - Restore and purge are admin work. Do them with a second client that has no data layer.
- Make unique keys partial (
WHERE "deletedAt" IS NULL), or a deleted row keeps its name forever.
Why the filter has to live below your queries
Here is a project with three tasks, one of which was deleted. This is an ordinary Prisma Next client with no data layer, loading projects with their tasks:
const projects = await db.orm.public.Project
.include('tasks')
.all();
// Website: Draft copy, Pick fonts, Dave only
"Draft copy" was deleted. It is back, because nothing filtered it.
The reason matters. Prisma Next does not load relations with a second query you
could intercept. It compiles the whole read into one SQL statement, and the
related rows arrive through a nested SELECT inside it. A filter that only looks
at the outermost table misses every nested one.
This is the statement @imqueue/pg-prisma sends for the same call, shortened to
the parts that matter:
SELECT "project"."id", …, (
SELECT json_agg(…) FROM (
SELECT … FROM "public"."task"
WHERE ("task"."projectId" = "project"."id"
AND "task"."deletedAt" IS NULL)
AND ("task"."workspaceId" = $1)
) AS "tasks__rows"
) AS "tasks"
FROM "public"."project"
WHERE "project"."deletedAt" IS NULL
AND ("project"."workspaceId" = $2)
Both SELECTs got both filters. The middleware walks the whole query tree before
it is turned into SQL and adds the condition to every SELECT that reads a
soft-deleted or scoped table. It does the same for a relation count:
include('tasks', t => t.count()) returned 2, not 3.
That is the whole argument for doing this in the data layer. A convention in code
review cannot see inside a compiled include. A middleware that rewrites the
query tree can.
Recipe 1: give your models the columns
Membership is by convention. A model that has a deletedAt field is
soft-deleted. A model with createdBy, updatedBy or deletedBy gets those
stamped. The package reads this from the contract.json that
prisma contract emit writes, so there is no second list to keep in sync with
the schema.
// use prisma-next
model Project {
id Int @id @default(autoincrement())
workspaceId String
name String
tasks Task[]
createdAt TimestamptzString @default(now())
updatedAt TimestamptzString?
deletedAt TimestamptzString?
createdBy String?
updatedBy String?
deletedBy String?
}
model Task {
id Int @id @default(autoincrement())
workspaceId String
ownerId String
assigneeId String?
title String
project Project @relation(fields: [projectId],
references: [id])
projectId Int
createdAt TimestamptzString @default(now())
updatedAt TimestamptzString?
deletedAt TimestamptzString?
createdBy String?
updatedBy String?
deletedBy String?
}
A few notes:
deletedAtmust be nullable. Null means the row is alive.- The package writes
updatedAtitself on every insert and update, so it does not need a default. TimestamptzStringstores a realtimestamptzand reads it back as text. On Node versions without a globalTemporal, that also avoids Prisma Next's Temporal-backed date types.- Different column names are fine. Pass them once as
fields, for examplefields: { deletedAt: 'removedAt' }.
Recipe 2: wire the data layer once
Install it next to Prisma Next:
npm i @imqueue/pg-prisma @prisma/orm-postgres
Keep the prisma CLI you already use to emit the contract; the two should come
from the same Prisma Next release. The package needs Node.js 22.12 or newer and
PostgreSQL 15 or newer. In an @imqueue
fleet it is the database toolkit to reach for, unless the fleet already runs on
Sequelize, in which case use @imqueue/pg-sequelize instead. Never install both.
The data layer needs two things from each request: who is acting, and
which rows they may see. Node's AsyncLocalStorage carries both without
passing them through every function:
// src/db.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import postgres from '@prisma/orm-postgres/runtime';
import { dataLayer } from '@imqueue/pg-prisma';
import type { Contract } from './prisma/contract.d.ts';
import contractJson from './prisma/contract.json'
with { type: 'json' };
export interface RequestContext {
userId: string;
workspaceId: string;
// set for members, left out for workspace admins
personId?: string;
}
export const requestContext =
new AsyncLocalStorage<RequestContext>();
const ctx = () => requestContext.getStore();
export const layer = dataLayer({
contract: contractJson,
scope: {
Project: { workspace: ['workspaceId'] },
Task: {
workspace: ['workspaceId'],
person: ['ownerId', 'assigneeId'],
},
},
resolvers: {
// no request: null, which shows nothing
workspace: () => ctx()?.workspaceId ?? null,
person: () => ctx()?.personId,
},
getActorId: () => ctx()?.userId ?? null,
});
export const db = postgres<Contract>({
contractJson,
url: process.env.DATABASE_URL!,
middleware: layer.middleware,
});
dataLayer() returns its middlewares already in order. That is deliberate:
you never arrange them yourself, so you cannot arrange them wrongly. Call
layer.close() on shutdown. It only has work to do if you turned on the audit
trail, but calling it is always safe.
Each request then runs inside its context:
await requestContext.run(
{ userId: 'alice', workspaceId: 'w1' },
async () => {
const tasks = await db.orm.public.Task.all();
// only live tasks in workspace w1
},
);
Recipe 3: what .delete() does now
Nothing changes at the call site:
const task = await db.orm.public.Task
.where({ id: 1 })
.delete();
This is what Postgres received:
UPDATE "public"."task"
SET "deletedAt" = $1, "updatedAt" = $2,
"updatedBy" = $3, "deletedBy" = $4
WHERE (("task"."id" = 1
AND "task"."deletedAt" IS NULL)
AND ("task"."workspaceId" = $5))
RETURNING …
Four things happened, and each one is worth knowing:
- The
DELETEbecame anUPDATE. The row is still in the table, withdeletedAtanddeletedBy: 'alice'set. - The caller still gets the row back. The
RETURNINGclause survives the rewrite, so code that used the deleted row keeps working. - Only live rows can be deleted. Deleting the same row a second time
returns
nulland changes nothing, so the originaldeletedAtanddeletedByare never overwritten. - The tenant filter came along. More on that in Recipe 5.
It is not only the ORM. A delete built with the SQL builder,
db.sql.public.task.delete().where(…), was rewritten the same way and reported
affectedRows: 1 with the row still in place. Any statement built as a query
tree goes through the middleware.
Updates are protected the same way. An update aimed at a deleted row returned
null and changed nothing, because the rewritten WHERE only matches live rows.
Authorship comes for free
The same middleware stamps who did what:
| operation | createdBy |
updatedBy |
deletedBy |
updatedAt |
|---|---|---|---|---|
| create as alice | alice | alice | — | now |
| update as erin | alice (kept) | erin | — | now |
| delete as alice | kept | alice | alice | now |
| create with no actor | null |
null |
— | now |
A write with no actor stamps null instead of skipping the column. That keeps a
system write (a nightly job, a migration script) distinguishable from a row
whose author was simply never recorded.
Recipe 4: tenant scoping, with levels
scope says which columns decide whether a row belongs to the caller. It is
keyed by model name, and each model can have several levels:
scope: {
Task: {
workspace: ['workspaceId'],
person: ['ownerId', 'assigneeId'],
},
},
Read it as: a task is visible if it is in your workspace, and you either own
it or it is assigned to you. Columns inside one level are OR-ed. Levels are
AND-ed together. Here is the WHERE for a member called Carol:
WHERE "task"."deletedAt" IS NULL
AND (("task"."workspaceId" = $1)
AND ("task"."ownerId" = $2
OR "task"."assigneeId" = $3))
With three live tasks in the table, this is what each caller saw:
| caller | workspace |
person |
tasks returned |
|---|---|---|---|
| Carol, a member | 'w1' |
'carol' |
1 (assigned to her) |
| Alice, a member | 'w1' |
'alice' |
1 (owned by her) |
| Alice, an admin | 'w1' |
undefined |
2 (all of w1) |
| a support tool | ['w1','w2'] |
undefined |
3 |
| anyone | null |
— | 0 |
| anyone | [] |
— | 0 |
The resolver's return value decides what a level does:
- a string matches rows where one of the level's columns equals it;
- an array matches rows where one of them is in the list;
nullor an empty array matches nothing. The SQL literally becomesAND (FALSE);undefinedturns the level off for this request.
That last rule is how the admin row works: an admin has no personId, so the
person level steps aside and only the workspace applies.
Scoping covers updates and deletes too. Alice, working in w1, tried to read,
update and delete a task from w2 by its id. All three returned null, and the
row was untouched. She cannot reach it even when she knows the id.
A scope entry with a typo is caught at start-up:
deriveDataLayer: scope names Tasks, which the contract
does not define
That is on purpose. A silently ignored typo would leave a model unscoped, and that mistake leaks data instead of throwing an error.
Recipe 5: when there is no request, show nothing
undefined meaning "this level is off" is useful for admins. It is dangerous
when nobody set a context at all. We ran Task.all() with an empty context:
every live task in every workspace came back, because every level was switched
off.
Three situations produce that empty context in real code:
- A background job or script that queries the database without calling
requestContext.run(). - A resolver that was never registered. A level named in
scopewith no resolver inresolversis skipped, just likeundefined. We declared aworkspacelevel, passedresolvers: {}, and got rows from both workspaces. - A query that runs after the context has ended. This one is subtle.
The third deserves its own example. In Prisma Next, .all() does not start the
query. It returns something you can await, and the query runs when you
await it. The resolvers are called at that moment, not when the query was
built:
// Wrong: returns the query unstarted; it runs
// after run() has already returned
const tasks = await requestContext.run(ctx, () =>
db.orm.public.Task.all(),
);
// Right: awaited while the context is active
const tasks = await requestContext.run(ctx,
async () => await db.orm.public.Task.all(),
);
The first version returned tasks from every workspace, filtered only by
deletedAt. The second returned what Carol was allowed to see. .all() does not
even return a real Promise: calling .catch() on it throws
TypeError: … .catch is not a function.
The defence for all three is the same: make the tenant level's resolver return
null when there is no context, as the ?? null in Recipe 2 does. We ran
Project.all() with no context at all against that resolver and got 0 rows.
Code that forgot its context now shows an empty page instead of the wrong
customer's data, and an empty page gets noticed.
Leave undefined for levels that are supposed to be optional, like person,
and give jobs that really need to see everything an explicit context that says
so.
Recipe 6: what scoping does not cover
Scoping filters which existing rows a statement may touch. It does not check what you write into them. Two measured cases:
- Inserts are not scoped. There is no existing row to filter. Alice, working
in
w1, created a task withworkspaceId: 'w2'. The insert succeeded, and she could not read the new task back, because it is now in someone else's workspace. - An update can move a row out. Alice updated one of her own tasks with
{ workspaceId: 'w2' }. TheWHEREonly checked that the row was inw1before the change, so the update went through and the task left her workspace.
The fix is ordinary code: take the tenant columns from the context, never from the request body.
export function createTask(input: NewTask) {
const { workspaceId, userId } = requestContext.getStore()!;
return db.orm.public.Task.create({
...input,
workspaceId, // from the context, not the input
ownerId: userId,
});
}
For updates, leave the tenant columns out of the fields a caller may set.
Recipe 7: restore and purge are admin work
A layered client cannot see deleted rows, so it cannot restore them either. We tried both routes:
// ORM: finds nothing, returns null
await db.orm.public.Task
.where({ id })
.update({ deletedAt: null });
// SQL builder: affectedRows 0
Both statements carried AND "deletedAt" IS NULL, so they matched nothing. The
same is true of a hard delete: the layered client always turns it into a stamp.
Keep a second client without the data layer for this, and keep it out of ordinary request code:
// src/admin-db.ts — sees every row, stamps nothing
export const adminDb = postgres<Contract>({
contractJson,
url: process.env.DATABASE_URL!,
});
// restore: clear the stamp and say who did it
await adminDb.orm.public.Task
.where({ id })
.update({
deletedAt: null,
deletedBy: null,
updatedBy: userId,
});
// purge: really remove the row
await adminDb.orm.public.Task.where({ id }).delete();
Both worked: the restored task was live again with deletedBy cleared, and the
purged one was gone from the table. Notice that the admin client stamps nothing,
so the restore sets updatedBy by hand.
Raw SQL is the other path around the layer, and it is worth knowing that it is
one. A statement written with db.raw.sql runs through the same client
without being rewritten. A raw UPDATE … SET "deletedAt" = NULL restored a
row even on the layered client, but left deletedBy in place, and a raw
SELECT count(*) counted all 13 rows in the table while the ORM on the same
client saw 5. Anything you write by hand is outside both soft delete and
scoping, so treat raw SQL with the same care as the admin client.
Recipe 8: unique keys and soft-deleted rows
Soft delete has one classic trap. A deleted row keeps its value in every unique
column. Delete a project called launch, try to create a new launch, and
Postgres refuses:
duplicate key value violates unique constraint
The row is invisible to your app, but the index still counts it. The fix is a partial unique index that only covers live rows, and Prisma Next can declare one in the schema:
model Project {
// …
slug String?
@@index([workspaceId, slug], unique: true,
where: "(\"deletedAt\" IS NULL)",
name: "project_slug_live")
}
Measured through the data layer:
| step | result |
|---|---|
create launch in w1 |
created |
create another live launch in w1 |
refused, 23505 |
| delete the first one | stamped |
create launch in w1 again |
created |
create launch in w2 |
created |
Duplicates among live rows are still refused, which is the rule you wanted in
the first place. Including workspaceId in the index makes the rule per tenant,
so two customers can both have a project called launch.
What this does not do
- It is not Postgres row-level security. The filters live in your service,
not in the database. Anything that reaches Postgres another way, such as
psql, a reporting tool, another service or raw SQL, sees every row. If you need the database itself to enforce tenant boundaries, that is RLS, and it is a separate decision. - It does not decide who belongs to which workspace. Scoping applies
whatever your resolvers return. Checking that a user may act in
w1happens before you putw1in the context. - It does not clean up deleted rows. They stay until you purge them. The same package can move aged rows into an archive schema on a schedule, and it can record every write in an audit table; both are opt-in, and both are documented in the API reference.
FAQ
How do I implement soft delete in Prisma?
Add a nullable deletedAt column, then rewrite deletes and reads in one place
rather than in every query. With @imqueue/pg-prisma on Prisma Next, any model
that has a deletedAt field is soft-deleted automatically: .delete() becomes
an UPDATE that sets the timestamp, and every SELECT gets
deletedAt IS NULL.
Does soft delete filter included relations?
With this data layer, yes. Prisma Next compiles an include into one statement
with nested selects, and the middleware adds the filter to every select that
reads a soft-deleted table. We measured it on include('tasks') and on an
include with count(). Without it, deleted related rows come back.
How do I scope Prisma queries by tenant?
Declare scope per model in dataLayer(), keyed by level, and give each level a
resolver that reads the current request. Reads, updates and deletes are
narrowed to matching rows. Inserts are not, so set tenant columns from the
request context yourself.
What happens if the resolver returns undefined?
That level is switched off for the request. That is useful for optional levels,
such as a per-person filter an admin should skip, but it means a missing context
removes the filter entirely. Return null from a tenant resolver when there is
no context: null matches nothing.
How do I restore a soft-deleted row?
Use a client created without the data layer and set deletedAt back to null.
The layered client cannot see deleted rows, so its updates match nothing. Clear
deletedBy and set updatedBy in the same update, because the plain client
stamps nothing.
Why does a unique constraint fail after a soft delete?
The deleted row is still in the table and still in the index. Make the unique
index partial, WHERE "deletedAt" IS NULL, so it only covers live rows. Prisma
Next can declare that with @@index([...], unique: true, where: "...").
Does raw SQL respect soft delete and scoping?
No. db.raw.sql statements run unchanged, even on a client with the data layer.
Only statements built by the ORM or the SQL builder are rewritten.
Is this the same as Postgres row-level security?
No. RLS is enforced by the database for every connection. This is enforced by your service for the queries it builds. They solve related problems and can be used together.
Reference
dataLayer() ·
DataLayerOptions ·
DataLayerOptions.getActorId ·
DataLayerOptions.resolvers ·
DeriveOptions.scope ·
DeriveFields ·
AccessScopeResolver ·
accessScope() ·
stamp() ·
StampColumns ·
deriveDataLayer() ·
@imqueue/pg-prisma ·
Least privilege for your Redis broker
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.