Skip to content

Repository files navigation

prisma-guard data boundary mark

prisma-guard

Define Prisma data boundaries once. Reuse them across your API.

Generated input validation and explicit query shapes keep allowed fields, response projection, and tenant scope in the Prisma call chain.

npm version npm downloads CI status code coverage license

npm · Install · Quick start · Guard API · Tenant scope · Limitations · Field Guide · Generated APIs

prisma-guard generates Prisma-aware validation and scope metadata, then enforces the operation shape you declare with .guard(shape). Client input is narrowed before Prisma runs; allowed projections and tenant context stay explicit at the data boundary.


Table of contents


Why this exists

Prisma is powerful, but most real backends eventually hit the same problems.

Missing input validation

await prisma.user.create({
  data: req.body,
})

The client controls the entire payload.

Dangerous query shapes

A client-controlled include chain can traverse relations and select sensitive fields from unrelated models:

await prisma.project.findMany({
  include: {
    tasks: {
      include: {
        comments: {
          include: {
            author: {
              select: {
                email: true,
                passwordHash: true,
                resetToken: true,
              },
            },
          },
        },
      },
    },
  },
})

Tenant isolation bugs

await prisma.project.findFirst({
  where: { id: { equals: projectId } },
})

If projectId belongs to another tenant, data can leak.

prisma-guard's answer is a tenant filter declared in the shape — see Tenant isolation.


What prisma-guard does

prisma-guard turns your Prisma schema into a runtime data boundary layer.

Feature Description
Input validation Zod schemas generated from Prisma types
Query shape enforcement Only allowed query shapes pass validation
Tenant isolation Explicit tenant filters via context-dependent shapes (recommended); automatic scope injection as a backstop
Schema-driven Rules come directly from your Prisma schema

The goal is simple:

Clients should not be able to accidentally or maliciously escape the data boundary defined by your schema.

prisma-guard is focused on data boundaries, not RBAC. Role-based access control is intentionally out of scope.


Validation philosophy

prisma-guard is intentionally not a strict scalar-validation framework by default.

The default runtime behavior focuses on:

  • enforcing the allowed query and data shape
  • rejecting unknown fields and unsupported operations
  • coercing common input values into Prisma-compatible types where reasonable
  • preventing dangerous ambiguity, destructive unconstrained operations, and silent shape misconfiguration

This means prisma-guard defaults are practical and frontend-friendly. Inputs often arrive from forms, query strings, JSON bodies, and frontend state where values may need light coercion before reaching Prisma.

Strict business validation is opt-in. Use one of these when exact scalar rules matter:

  • @zod directives in the Prisma schema
  • inline refine functions in data, create, or update shapes
  • guard.input({ refine })
  • application-level validation before calling guarded Prisma methods
model User {
  id    String @id @default(cuid())
  /// @zod .email().max(255)
  email String
}
await prisma.user
  .guard({
    data: {
      age: (base) => base.int().min(18).max(120),
    },
  })
  .create({ data: req.body })

Scalar coercion defaults

Default scalar validation is intentionally permissive where that is useful and Prisma-compatible.

Examples:

  • Int accepts practical numeric input and coerces to an integer value.
  • Float accepts JavaScript numeric input.
  • DateTime accepts values that can be parsed into a JavaScript Date.
  • Decimal accepts JavaScript number, decimal string, and Decimal-like objects unless strict Decimal mode is enabled.

For stricter behavior, add @zod rules or a refine function. The guard should stay easy to use by default; exact business rules belong in explicit validation.

This leniency does not apply to boundary safety. prisma-guard should still reject unknown fields, invalid shape keys, unsafe projection behavior, unconstrained destructive nested writes, and shape configs that look restrictive but would otherwise be silently ignored.


Architecture

prisma-guard sits between your application and Prisma Client. The .guard(shape) call defines the boundary; the chained Prisma method validates and executes in one step.

Tenant isolation is layered: tenant filters declared in the shape are the primary enforcement; the scope extension is an automatic backstop for top-level operations.

┌───────────────┐
│   Client      │
│  (API / RPC)  │
└───────┬───────┘
        │ request
        ▼
┌──────────────────────────┐
│  .guard((ctx) => shape)  │
│  validates input +       │
│  enforces query shape    │
│  tenant filters          │
│  (primary enforcement)   │
└─────────┬────────────────┘
          │ validated args
          ▼
┌──────────────────────────┐
│ Scope Extension Layer    │
│ (Prisma extension)       │
│ tenant backstop          │
│ (top-level ops only)     │
└─────────┬────────────────┘
          │ scoped query
          ▼
┌──────────────────────┐
│    Prisma Client      │
└─────────┬────────────┘
          │ SQL
          ▼
┌──────────────────────┐
│       Database        │
└──────────────────────┘

Install

npm install prisma-guard zod @prisma/client

Peer dependencies:

  • zod ^4
  • @prisma/client ^6 || ^7

Both zod and @prisma/client must be installed before running prisma generate. The generator validates @zod directives against real Zod schemas at generation time.

Generated output files (index.ts, client.ts, and optionally shapes.ts) are TypeScript. A TypeScript-capable build pipeline is required.

Generated internal imports follow the consuming project's TypeScript setup. By default, importStyle = "auto" reads the nearest tsconfig.json from the generator output directory, follows relative and package-style extends, and chooses extensionless, .js, or .ts imports based on module, moduleResolution, allowImportingTsExtensions, and nearest package.json "type". This keeps CommonJS/classic TypeScript projects on extensionless imports and NodeNext/Node16 ESM projects on .js imports. If auto-detection does not match your build, set importStyle explicitly.


Quick start

1. Add the generator

generator guard {
  provider = "prisma-guard"
  output   = "generated/guard"
}

2. Generate

npx prisma generate

This emits a ready-to-use client.ts with all type mappings and a pre-wired guard instance.

3. Set up the Prisma client

import { AsyncLocalStorage } from 'node:async_hooks'
import { PrismaClient } from '@prisma/client'
import { guard } from './generated/guard/client'

const store = new AsyncLocalStorage<{ tenantId: string }>()

const prisma = new PrismaClient().$extends(
  guard.extension(() => ({
    Tenant: store.getStore()?.tenantId,
  }))
)

guard.extension() uses one Prisma $allModels entry internally. Direct access to extension.model.<model>.guard is unsupported. Apply the extension with $extends, then call prisma.<model>.guard(...).

This extension is the automatic scope backstop: it injects scope foreign keys on creates, rejects unsafe scoped findUnique, and fails closed when tenant context is missing. Keep it enabled even when you declare tenant filters in shapes.

4. Provide request context

The context function reads from AsyncLocalStorage, so each request needs to run inside a store scope:

await store.run({ tenantId }, async () => {
  await handler()
})

In a web framework, set the store once per request and run the route handler inside it.

5. Enforce tenants in the shape (multi-tenant)

For multi-tenant systems, declare tenant filters explicitly with a context-dependent shape. This is the recommended isolation mechanism:

import { force } from 'prisma-guard'

await prisma.project
  .guard((ctx) => ({
    where: { tenantId: { equals: force(ctx.Tenant) } },
    select: { id: true, title: true },
  }))
  .findMany(req.body)

Tenant filters written in the shape apply at the top level and inside every nested to-many include the shape exposes. They are visible in code review, greppable, and cannot be widened by client input.

6. Use it

await prisma.project
  .guard({
    data: { title: true },
  })
  .create({ data: req.body })

await prisma.project
  .guard({
    where: { title: { contains: true } },
    orderBy: { title: true },
    take: { max: 50, default: 20 },
  })
  .findMany(req.body)

That's it. Input is validated, query shape is enforced, tenant filters from the shape are applied, and the scope extension runs as a backstop. All in one chain.


Generator configuration

All generator config values are strings because Prisma generator config is string-based.

generator guard {
  provider                = "prisma-guard"
  output                  = "generated/guard"

  onInvalidZod            = "error" // error | warn
  onAmbiguousScope        = "error" // error | warn | ignore
  onMissingScopeContext   = "error" // error | warn | ignore
  findUniqueMode          = "reject" // reject | verify
  onScopeRelationWrite    = "error" // error | warn | strip

  strictDecimal           = "false" // true | false
  enforceProjection       = "false" // true | false
  typedGuardShapes        = "true"  // true | false
  typedGuardRelationDepth = "1"     // 0 | 1 | 2 | 3

  importStyle             = "auto"  // auto | none | js | ts
  runtimeImportPath       = "prisma-guard"
}

Typed shape generation depth

typedGuardShapes controls whether the generator emits shapes.ts helper types.

When typedGuardShapes = "false", prisma-guard does not emit shapes.ts. If an older generated shapes.ts exists in the output directory, it is removed on the next generation.

typedGuardRelationDepth controls how deeply generated TypeScript helper types expand relation shapes:

Value Behavior
"0" Do not expand relation shapes in generated helper types
"1" Expand direct relations; default
"2" Expand relations two levels deep
"3" Expand relations three levels deep

Higher values give richer editor assistance for nested projections and relation filters, but can make generated types heavier. Runtime validation is not limited by this setting.

Import style

importStyle controls imports inside generated client.ts and shapes.ts.

Value Behavior
"auto" Read project tsconfig.json and nearest package.json to choose the import style
"none" Use extensionless imports like ./index
"js" Use .js imports like ./index.js
"ts" Use .ts imports like ./index.ts

Auto mode chooses:

  • "ts" when allowImportingTsExtensions is true
  • "js" for module or moduleResolution values like Node16, Node18, or NodeNext
  • "none" for classic CommonJS-style resolution like node, node10, classic, or bundler
  • "js" when no decisive tsconfig.json setting is found but nearest package.json has "type": "module"
  • "none" as final fallback

runtimeImportPath controls where generated files import runtime APIs from. The default is prisma-guard. This is mainly useful for monorepos, local package aliases, or development builds.


Before / After prisma-guard

Without prisma-guard

await prisma.project.create({
  data: req.body,
})

await prisma.project.findMany(req.query)

await prisma.project.findFirst({
  where: { id: { equals: projectId } },
})

Problems:

  • unvalidated input
  • unrestricted query shape
  • missing tenant filter

With prisma-guard

await prisma.project
  .guard({ data: { title: true } })
  .create({ data: req.body })

await prisma.project
  .guard({
    where: { title: { contains: true } },
    take: { max: 50, default: 20 },
  })
  .findMany(req.body)

await prisma.project
  .guard({
    where: { id: { equals: true } },
  })
  .findFirst({ where: { id: { equals: projectId } } })
Risk Mitigation
arbitrary input data shape restricts writable fields + Zod
expensive queries shape whitelist on where, include, take, orderBy
cross-tenant access tenant filters in context-dependent shapes; scope extension as backstop

Schema annotations

Mark tenant root models

Use @scope-root on models that represent tenant roots.

/// @scope-root
model Tenant {
  id   String @id @default(cuid())
  name String
}

Models with a single unambiguous foreign key to a scope root are auto-scoped. A model can be scoped by multiple roots if it has foreign keys to different scope root models — see Multi-root scope behavior.

If a model has multiple foreign keys to the same scope root, the ambiguous root is excluded from that model's scope entries when onAmbiguousScope is "warn" or "ignore", and causes a generation error when onAmbiguousScope is "error" (the default). Other non-ambiguous roots on the same model are still auto-scoped.

Scope root models themselves are context roots. They are not automatically scoped by their own @scope-root marker. For example, if Tenant is marked @scope-root, child models with tenantId are scoped, but direct tenant.findMany() calls are not scoped by the tenant root marker. Do not expose root model delegates directly unless you add your own shape rules or application-level authorization.

Add field-level validation with @zod

model User {
  id    String @id @default(cuid())
  /// @zod .email().max(255)
  email String
  /// @zod .min(1).max(100)
  name  String?
}

@zod chains apply automatically when the field appears in a data shape with true.

@zod directives are validated during prisma generate. The generator validates directive syntax, checks that each chained method is in the allowed list, checks argument count, and attempts to construct the final Zod schema from the generated base type. Invalid chains such as .nullable().email() are rejected because schema construction fails. Some argument-level type mismatches are only caught if Zod throws while the schema is being constructed.

For list fields, @zod chains apply to the z.array(...) schema, not to individual elements. For example, .min(1) on a String[] field enforces a minimum array length of 1, not a minimum string length.

Supported @zod methods

The @zod DSL supports a restricted subset of Zod methods. These are the allowed methods:

String validations: min, max, length, email, url, uuid, cuid, cuid2, ulid, trim, toLowerCase, toUpperCase, startsWith, endsWith, includes, regex, datetime, ip, cidr, date, time, duration, base64, nanoid, emoji

Number validations: int, positive, nonnegative, negative, nonpositive, finite, safe, multipleOf, step, gt, gte, lt, lte

Array validations: min, max, length, nonempty

Field modifiers: optional, nullable, nullish, default, catch, readonly

Note on field modifiers: prisma-guard already handles optional and nullable based on Prisma field metadata (isRequired, hasDefault). Adding @zod .optional() or @zod .nullable() explicitly will apply the Zod method on top of what prisma-guard already does, which may cause double-wrapping. Use these only when you need to override prisma-guard's default behavior.

Note on default and catch: a @zod .default(...) chain adds a Zod-level default, which means Zod will fill in the value if it's undefined. A @zod .catch(...) chain provides a fallback value when parsing fails. The generator detects both .default() and .catch() in chains and emits a ZOD_DEFAULTS map. The create completeness check honors Prisma's @default attribute, @zod .default(...), and @zod .catch(...) — a required field with any of these sources of default is not flagged as missing from create data shapes. Prisma's @default remains the primary source of truth; @zod .default(...) and @zod .catch(...) are additional signals.

When a field has @zod .default(...) or @zod .catch(...) and appears in a data shape as true, prisma-guard preserves the Zod default/catch behavior in create mode by not wrapping the schema with .optional(). This ensures that omitting the field from client input triggers the Zod default or catch value rather than passing undefined through. If such a field is omitted from the data shape entirely (not listed as a key), the runtime auto-injects its default value as a forced field — the client cannot provide it, and the Zod default is always applied.

Note on chain ordering: type-changing methods like .nullable(), .optional(), .default(), and .catch() alter the wrapper type. Methods that follow a type-changing method must exist on the resulting wrapper, not on the original base type. For example, .email().nullable() is valid (.email() returns a string schema, .nullable() wraps it), but .nullable().email() is invalid (.nullable() returns a nullable wrapper that does not have .email()).

Supported argument types in @zod directives

The directive parser accepts these argument types: strings ('hello', "hello"), numbers (42, -3.14, 1e2), booleans (true, false), arrays ([1, 2, 3]), regex literals (/^[a-z]+$/i), and object literals ({offset: true}). Identifiers, template literals, null, NaN, Infinity, and function calls are not allowed.

refine replaces @zod chains

Both guard.input({ refine }) and inline refine functions in data shapes bypass @zod chains. The function receives the base Zod type (without @zod chains applied). This is by design — refine is a full override, not a modifier on top of @zod.

guard.input('User', {
  refine: {
    email: (base) => base.email().max(320),
  },
})

In this example, any @zod directive on the email field in the Prisma schema is ignored. The refine callback is the sole source of validation for that field.

Refine callbacks and inline refine functions must return a valid Zod schema. If the callback throws or returns a non-Zod value, a ShapeError is raised.

When a refine function returns a schema that handles undefined input (e.g. by including .default(...) or .catch(...)), prisma-guard detects this at runtime and preserves the default/catch behavior by not wrapping the schema with .optional() in create mode.


The guard API

.guard(shape, caller?) is available on every model delegate. It returns an object with all Prisma methods plus resolve(). The shape defines the boundary; the chained Prisma method validates and executes. resolve() is a read-planning helper that resolves the same shape boundary without executing Prisma.

Data shape syntax

Each field in a data shape accepts these value types:

  • true — the client may provide this value; @zod chains apply automatically
  • literal value — the server forces this value; the client cannot override it
  • force(value) — the server forces this value; required when the value is literally true (see The force() helper)
  • unsupported() — explicitly acknowledge and omit an Unsupported(...) Prisma field from client input
  • function (base) => schema — the client may provide this value; the function receives the base Zod type (without @zod chains) and returns a refined schema
import { force } from 'prisma-guard'

await prisma.project
  .guard({
    data: {
      title: (base) => base.min(1, 'Title required').max(200),
      status: true,
      priority: (base) => base.refine(v => v >= 1 && v <= 5, 'Priority 1-5'),
      createdBy: currentUserId,
      isActive: force(true),
    },
  })
  .create({ data: req.body })

In this example, title and priority use inline refines for custom validation and error messages, status uses @zod chains from the Prisma schema, createdBy is forced to currentUserId, and isActive is forced to true using the force() helper.

Relation fields are supported in data shapes with a config object describing allowed nested write operations. See Relation writes in data shapes for syntax and security implications.

The force() helper

The value true in a shape always means "client-controlled." This creates ambiguity when you need to force a boolean field to the literal value true. The force() helper resolves this:

import { force } from 'prisma-guard'

data: { isActive: true }         // client-controlled — client sends any boolean
data: { isActive: false }        // forced to false
data: { isActive: force(true) }  // forced to true
data: { isActive: force(false) } // also valid — equivalent to just false

force() works in both data shapes and where shapes:

where: {
  published: { equals: true },          // client-controlled — client sends any boolean
  isDeleted: { equals: false },          // forced to false
  isActive: { equals: force(true) },     // forced to true
}

force() wraps the value in a marker object. It can wrap any value type, not just booleans. Using force() on non-true values is allowed but unnecessary — only the literal true collides with the client-controlled sentinel.

Unsupported Prisma field types

Prisma fields declared as Unsupported(...) are never client-controlled. A data shape like this is rejected:

data: { rawVector: true }

Use unsupported() when the field exists in the Prisma schema but should be intentionally omitted from guarded input:

import { unsupported } from 'prisma-guard'

data: { rawVector: unsupported() }

You may still force an unsupported field from trusted server code:

data: { rawVector: serverComputedValue }

Unsupported fields are not filterable in where shapes.

Unsupported fields are also not exposed through guard.input() or guard.model() by default. In data shapes, unsupported fields cannot be client-controlled; use unsupported() to acknowledge that the field is intentionally omitted from guarded input, or provide a forced server-side value.

Query shape syntax

For read operations, true means the client may provide this value and literal values are forced:

Reads

await prisma.project
  .guard({
    where: { title: { contains: true } },
    orderBy: { title: true },
    take: { max: 100, default: 25 },
  })
  .findMany(req.body)

The client can only filter by title, sort by title, and take up to 100 rows. Everything else is rejected.

Take shorthand

take accepts either an object or a number. When a number is provided, it serves as both the maximum and the default:

take: 50                       // equivalent to { max: 50, default: 50 }
take: { max: 100, default: 25 } // client may send 1..100; omitted value becomes 25
take: { max: 100 }              // client may send 1..100; omitted value stays omitted

This shorthand applies anywhere take is supported, including nested relation projections. Use { max } without default only when an omitted take should remain omitted.

Order by shapes

orderBy shape config uses true per sortable field:

orderBy: { createdAt: true, title: true }

Client input then uses Prisma sort directions:

{ orderBy: { createdAt: 'desc' } }

Do not put filter operators under orderBy. This is rejected:

orderBy: { title: { contains: true } }

For groupBy, normal grouped fields and _count fields must also be configured with true.

Unique where shapes

findUnique, findUniqueOrThrow, update, delete, and upsert use Prisma's WhereUniqueInput syntax.

For these methods, configure selector fields directly in the shape:

await prisma.project
  .guard({
    data: { title: true },
    where: { id: true },
  })
  .update({
    data: { title: 'Updated' },
    where: { id: 'abc123' },
  })

Do not use normal WhereInput filter operator objects such as { id: { equals: true } } in unique where shapes. That syntax belongs to normal filter shapes used by methods such as findMany, findFirst, count, updateMany, and deleteMany.

Extra non-unique filters in update and delete

Prisma WhereUniqueInput can include additional non-unique scalar filters alongside a unique selector. This is useful for permission checks.

For example, this is valid Prisma-style update filtering:

await prisma.post.update({
  where: {
    id: postId,
    authorId: userId,
  },
  data: {
    title: 'Updated',
  },
})

In prisma-guard shape syntax, model this with direct fields, not filter operators:

await prisma.post
  .guard({
    data: { title: true },
    where: {
      id: true,
      authorId: force(userId),
    },
  })
  .update({
    data: { title: 'Updated' },
    where: { id: postId },
  })

The shape above means:

  • id: true is client-controlled and must be provided by the caller.
  • authorId: force(userId) is server-controlled and is merged into the final Prisma where.
  • The final Prisma call is constrained by both id and authorId.

Do not write the same shape as:

where: {
  id: true,
  authorId: { equals: force(userId) },
}

authorId: { equals: ... } is normal WhereInput filter syntax, not direct WhereUniqueInput syntax.

At least one unique selector must still be present. Extra non-unique filters are additional constraints; they do not replace the unique selector requirement.

Top-level unique where vs relation write selectors

The direct forced-field syntax applies to top-level unique where shapes used by methods such as update, delete, upsert, findUnique, and findUniqueOrThrow.

where: {
  id: true,
  companyId: force(ctx.companyId),
}

This lets the client provide id, while the server forces companyId into the final Prisma where.

Do not assume the same forced-field syntax works inside relation write selectors such as connect, disconnect, set, nested delete, nested update.where, or connectOrCreate.where. Relation write selectors use their own validation path and should be configured as normal unique selector allowlists:

data: {
  tags: {
    connect: { id: true },
  },
}

If you need tenant-safe relation writes, validate ownership in application code, use a top-level guarded operation, or rely on database-level constraints such as foreign keys, RLS, or triggers. Nested relation writes bypass automatic tenant scope injection.

Multiple unique selectors

If a model has multiple single-field unique selectors and the shape lists more than one, the client may use any allowed selector.

where: {
  id: true,
  slug: true,
}

This allows a request with:

where: { id: 'project_1' }

or:

where: { slug: 'my-project' }

For compound unique constraints, use Prisma's generated compound selector name:

model ProjectMember {
  tenantId String
  userId   String

  @@unique([tenantId, userId])
}
await prisma.projectMember
  .guard({
    where: {
      tenantId_userId: {
        tenantId: true,
        userId: true,
      },
    },
  })
  .update({
    data: req.body.data,
    where: {
      tenantId_userId: {
        tenantId: 'tenant_1',
        userId: 'user_1',
      },
    },
  })

Named compound constraints use the configured name as the selector:

@@unique([tenantId, slug], name: "project_slug_per_tenant")
where: {
  project_slug_per_tenant: {
    tenantId: true,
    slug: true,
  },
}

Creates

await prisma.project
  .guard({
    data: { title: true, status: true },
  })
  .create({ data: req.body })

Only title and status are accepted from the client. @zod chains apply automatically.

For create operations, guard validates that all required fields without defaults are accounted for in the data shape — either as client-allowed (true or function), forced (literal value or force()), as scope foreign keys that the scope extension will inject automatically, or as fields with a @zod .default(...) or @zod .catch(...) directive. If a required field is missing from the shape and has no Prisma @default, no @zod .default(...) or @zod .catch(...), and is not a scope FK, guard throws ShapeError at shape evaluation time.

Fields with @zod .default(...) or @zod .catch(...) that are omitted from the data shape are automatically injected as forced values at runtime. The Zod schema is evaluated with undefined input and the resulting default or catch value is included in the create data. This ensures the field always has a value without requiring the client to provide one or the developer to list it in the shape.

Updates

await prisma.project
  .guard({
    data: { title: true },
    where: { id: true },
  })
  .update({
    data: { title: 'New title' },
    where: { id: 'abc123' },
  })

In update mode, all data fields are optional. The where shape must use Prisma WhereUniqueInput syntax for update.

For permission checks, you may include additional direct scalar filters alongside a unique selector:

where: {
  id: true,
  companyId: force(ctx.companyId),
}

Do not use normal filter operator syntax such as { companyId: { equals: force(ctx.companyId) } } in an update unique where shape.

Forced values

Literal values in the shape are forced by the server and cannot be overridden by the client:

import { force } from 'prisma-guard'

await prisma.project
  .guard({
    data: { title: true, status: 'draft', isActive: force(true) },
  })
  .create({ data: req.body })

status is always 'draft' and isActive is always true regardless of what the client sends.

The same applies to where:

await prisma.project
  .guard({
    where: {
      status: { equals: 'published' },
      isActive: { equals: force(true) },
      title: { contains: true },
    },
  })
  .findMany(req.body)

status = 'published' and isActive = true are always enforced. The client can only control the title filter.

Forced where conditions are conflict-checked during shape construction. If the same field and operator appear with different forced values in different parts of a shape (e.g. at the top level and inside a combinator), the shape is rejected with ShapeError. This prevents ambiguous security configurations where one forced value would silently overwrite another. Forced NOT conditions are preserved as separate logical NOT branches when merged with client-provided NOT, rather than being merged like scalar fields.

Deletes

await prisma.project
  .guard({
    where: { id: true },
  })
  .delete({ where: { id: 'abc123' } })

data is not valid for delete shapes. The where shape must use Prisma WhereUniqueInput syntax for delete.

For permission checks, you may include additional direct scalar filters alongside a unique selector:

where: {
  id: true,
  companyId: force(ctx.companyId),
}

Do not use normal filter operator syntax such as { companyId: { equals: force(ctx.companyId) } } in a delete unique where shape.

Batch creates

await prisma.project
  .guard({
    data: { title: true, status: true },
  })
  .createMany({
    data: [
      { title: 'Project A', status: 'active' },
      { title: 'Project B', status: 'draft' },
    ],
  })

Each item in the array is validated against the same data shape.

In guarded mode, createMany and createManyAndReturn require data to be an array. Single-object data is not silently wrapped.

createMany and createManyAndReturn also accept skipDuplicates: boolean in the request body. This is passed through to Prisma without shape-level configuration.

Bulk mutations

updateMany, updateManyAndReturn, and deleteMany require a where shape in the guard definition. This prevents accidental unconstrained bulk writes.

await prisma.project
  .guard({
    data: { status: true },
    where: { status: { equals: true } },
  })
  .updateMany({
    data: { status: 'archived' },
    where: { status: { equals: 'draft' } },
  })

A guard shape without where on a bulk mutation method throws ShapeError. Additionally, if the client provides no where conditions at runtime (or the resolved where is empty), the request is rejected. Each where operator object must contain at least one operator with a value — empty operator objects like { status: {} } are rejected.

When combinators (AND, OR, NOT) are exposed in the where shape, the guard also prevents vacuous filters. Combinator arrays must contain at least one element, and each element must specify at least one condition with a defined value. Structures like { AND: [] }, { AND: [{}] }, and { NOT: [] } are rejected. See Logical combinators in where shapes for details.

Mutation body validation

Mutation bodies are strictly validated. The accepted keys depend on whether the shape defines a return projection (select or include):

Without projection in shape:

  • create: data
  • createMany, createManyAndReturn: data, skipDuplicates
  • update, updateMany, updateManyAndReturn: data, where
  • upsert: where, create, update, select, include
  • delete, deleteMany: where

With projection in shape (methods that support it):

  • create: data, select, include
  • createManyAndReturn: data, select, include, skipDuplicates
  • update, updateManyAndReturn: data, where, select, include
  • upsert: where, create, update, select, include
  • delete: where, select, include

For createMany and createManyAndReturn, skipDuplicates is also accepted as a body key. It must be a boolean if provided.

Unknown keys are rejected with ShapeError. If the body contains select or include but the shape does not define them, the request is rejected.

Guard shape keys are also validated per method:

  • create methods accept data, and optionally select/include (if the method supports projection)
  • update methods accept data, where, and optionally select/include
  • upsert accepts where, create, update, and optionally select/include
  • delete methods accept where, and optionally select/include

Shape keys not valid for the method throw ShapeError.

Supported shape keys

For reads: where, include, select, orderBy, cursor, take, skip, distinct, _count, _avg, _sum, _min, _max, by, having

For writes: data, where, select, include (select/include only on methods that return records)

For upsert: where, create, update, select, include

Where shapes accept scalar field filters, relation filters (some, every, none, is, isNot), and logical combinators (AND, OR, NOT).

Shape config value validation

Shape config values are strictly validated at construction time. Fields in orderBy, cursor, having, _count (object form), _avg, _sum, _min, _max must have the value true. The skip config must be exactly true. Passing any other value (including false, numbers, or strings) throws ShapeError. This prevents accidental misconfiguration where a developer writes { orderBy: { title: false } } expecting it to disable ordering — instead of silently enabling it, the shape is rejected.

Where DSL: Prisma-compatible subset

The where shape syntax supports a subset of Prisma's where filter API. This section documents both supported features and known differences.

Supported operators:

All standard scalar operators are supported: equals, not, contains, startsWith, endsWith, in, notIn, gt, gte, lt, lte, search.

The not operator accepts either a scalar value or a nested filter object:

where: {
  age: { not: true },  // client can send { age: { not: 5 } } or { age: { not: { gt: 5 } } }
}

The search operator is supported for String fields with @@fulltext indexes:

where: {
  title: { search: true },
}

Case-insensitive string filtering with mode:

String fields support Prisma's mode modifier alongside contains, startsWith, endsWith, and equals. Use mode: true to let the client choose, or force a specific mode in the shape:

where: {
  // client controls mode
  title: { contains: true, mode: true },

  // server forces case-insensitive matching
  description: { contains: true, mode: 'insensitive' },

  // server forces case-insensitive matching using force()
  slug: { contains: true, mode: force('insensitive') },
}

mode is also supported on Json fields with the string_contains, string_starts_with, and string_ends_with operators. The shape must include at least one mode-compatible operator alongside mode{ title: { mode: true } } alone is rejected.

When mode is forced (e.g. mode: 'insensitive') and the client provides a compatible operator value (e.g. { contains: 'foo' }), the forced mode is inlined into the same operator object, producing { contains: 'foo', mode: 'insensitive' } in the final Prisma query. This is required for mode to actually affect the query — Prisma's mode is a modifier that must be co-located with the string operator it modifies.

Relation existence checks with is: null / isNot: null:

To-one relation filters support null checks for testing relation existence. In the shape config, use null as the operator value to force a null check:

where: {
  author: {
    is: null,      // forced: always filters for records where author IS null
  },
}

This produces { author: { is: null } } in the Prisma query. Since null in a shape is always a forced value (the client cannot control it), this is equivalent to a forced where condition.

Notable differences from raw Prisma where clauses:

  • AND and OR in client input must be arrays with at least one element. Prisma accepts a single object for AND; prisma-guard requires an array. Empty arrays are rejected.
  • NOT in client input accepts a single object or an array with at least one element. Empty arrays are rejected.
  • Each combinator member must specify at least one condition with a defined value. Empty objects inside combinators (e.g. { AND: [{}] }) are rejected when no forced values exist.
  • Relation filter operators (some, every, none, is, isNot) require at least one nested condition when all conditions are client-controlled. Empty relation filters like { posts: { some: {} } } are rejected.
  • Relation operator containers require at least one operator. { posts: {} } is rejected.
  • Empty combinator and relation filter definitions in shapes are rejected at shape construction time. A shape like where: { AND: {} } throws ShapeError.
  • Forced where conditions are conflict-checked at shape construction time. The same field and operator with different forced values across shape branches (e.g. top-level vs inside a combinator) is rejected with ShapeError.

These restrictions are intentional. They prevent clients from sending structurally valid but semantically vacuous filters that could broaden query scope, particularly in bulk mutation where clauses.

Body normalization

Read methods and mutation methods accept undefined or null as body input across all API surfaces. Missing bodies are consistently normalized to {} (empty object). This applies to single shapes, named shapes, and guard.query().parse(). An explicit body, when provided, must be a plain object.

Supported methods

Reads: findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, count, aggregate, groupBy

Writes: create, createMany, createManyAndReturn, update, updateMany, updateManyAndReturn, upsert, delete, deleteMany

findManyPaginated

findManyPaginated is an intentional operation shape used by the integrated prisma-generator-express stack.

It is not expected to map 1:1 to a native Prisma Client method. It exists so guard shapes can be generated for the paginated route/helper layer.


Read planning with resolve()

.guard(shape, caller?).resolve(body?) resolves a read shape without executing a Prisma query.

Use it when integration code needs to inspect the concrete guard shape and effective read body before deciding how to run a read. Typical use cases are generated routers, progressive response streaming, query planners, and adapters that need the same shape resolution as the real guarded method.

const resolved = prisma.user
  .guard(
    {
      me: (ctx) => ({
        where: {
          id: { equals: force(ctx.userId) },
        },
        select: {
          id: true,
          email: true,
          profile: {
            select: {
              id: true,
              displayName: true,
            },
          },
        },
      }),
    },
    'me',
  )
  .resolve()

The return value is:

{
  shape: GuardShape
  body: Record<string, unknown>
  effectiveReadBody: Record<string, unknown>
  matchedKey: string
  wasDynamic: boolean
}
Field Meaning
shape The concrete resolved guard shape after caller matching and dynamic shape execution
body The normalized request body. Omitted input becomes {}
effectiveReadBody The body used for read planning. If the body has no select or include, the shape's default read projection is applied
matchedKey The selected declared key, such as default, admin, or /user/:id. For parameterized callers this is the pattern key, not the concrete caller value
wasDynamic true when the matched shape was a function and was executed with guard context

resolve() uses the same guard extension context and caller selection path as .findMany(), .findFirst(), and the other guarded methods. If the shape is context-dependent, the shape function is called with the current guard context.

resolve() is intentionally read-only. It rejects top-level data, create, and update keys on either the resolved shape or the request body. Use the corresponding guarded write method for writes.

effectiveReadBody is planning input, not final Prisma args. It does not replace the normal guarded read execution pipeline. The actual guarded read method still validates the body, applies forced values, applies default projection rules, injects scope filters, and calls Prisma.

No Prisma delegate method is called by resolve().

Relation writes in data shapes

Data shapes support relation fields with a config object describing which nested write operations the client may use. Each operation (connect, create, disconnect, etc.) is configured individually.

⚠️ Security warning: The automatic tenant scope extension only intercepts top-level operations. Nested writes through relation configs bypass scope entirely — no FK injection on nested creates, no tenant filtering on nested updates/deletes, and no tenant filtering on nested connects. If you use relation writes on scoped models, handle tenant isolation manually in application code or enforce it via database constraints such as RLS, triggers, and foreign keys.

Be especially careful with destructive nested operations. An unconstrained nested delete such as { posts: { deleteMany: {} } } can delete all related children under the parent. prisma-guard treats unconstrained destructive nested writes as unsafe boundary configuration; expose them only through explicit server-side code.

Syntax

await prisma.post
  .guard({
    data: {
      title: true,
      content: true,
      tags: {
        connect: { id: true },
        disconnect: { id: true },
      },
    },
    where: { id: true },
  })
  .update({
    data: {
      title: 'Updated',
      tags: {
        connect: [{ id: 'tag1' }, { id: 'tag2' }],
        disconnect: [{ id: 'tag3' }],
      },
    },
    where: { id: 'post1' },
  })

Supported operations

All 11 Prisma nested write operations are supported:

Operation To-one To-many Config type
connect yes yes unique selector config, e.g. { id: true }
connectOrCreate yes yes { where: unique selector, create: { ... } }
create yes yes { fieldName: true, ... }
createMany no yes { data: { fieldName: true, ... } }
disconnect yes yes true (to-one) or unique selector config (to-many)
delete yes yes true (to-one) or unique selector config (to-many)
set no yes unique selector config
update yes yes { fieldName: true } or { where: unique selector, data: ... }
updateMany no yes { where: { ... }, data: { ... } }
upsert yes yes { where?: unique selector, create: { ... }, update: { ... } }
deleteMany no yes filter config; unconstrained empty object is unsafe

For to-many relation operations that use unique selectors, prisma-guard accepts Prisma-compatible single-object and array forms:

tags: {
  connect: { id: 'tag1' },
  disconnect: [{ id: 'tag2' }],
  set: { id: 'tag3' },
}

Empty arrays are accepted where Prisma accepts them, for example set: [] to clear a to-many relation.

Example with multiple operations

await prisma.user
  .guard({
    data: {
      name: true,
      posts: {
        create: { title: true, content: true },
        connect: { id: true },
        disconnect: { id: true },
        update: {
          where: { id: true },
          data: { title: true },
        },
      },
    },
    where: { id: true },
  })
  .update({
    data: {
      name: 'Updated Name',
      posts: {
        create: { title: 'New Post', content: 'Content' },
        connect: [{ id: 'existing-post-id' }],
      },
    },
    where: { id: userId },
  })

Validation

Each operation's config is validated at shape construction time:

  • Unknown operations throw ShapeError
  • Operations invalid for the relation cardinality throw ShapeError (e.g. set on to-one, disconnect: true on to-many)
  • Nested data fields are validated against the related model's type map
  • Relation fields inside nested data are not recursively expanded; nested write shapes support one relation-write level
  • Nested create paths validate configured fields with create semantics, but final required-field completeness may still be enforced by Prisma
  • Nested update paths use update semantics: all configured data fields are optional at runtime
  • Operations that use Prisma WhereUniqueInput semantics should be configured with unique fields or compound unique selector names
  • @zod chains apply to nested data fields

Unique selectors in relation writes

Relation write operations such as connect, disconnect, delete, set, connectOrCreate.where, update.where, and upsert.where use Prisma unique selector semantics.

For single-field unique selectors, configure the field directly:

posts: {
  connect: { id: true },
}

For compound unique constraints, use Prisma's compound selector name, same as top-level unique where shapes:

members: {
  connect: {
    tenantId_userId: {
      tenantId: true,
      userId: true,
    },
  },
}

Unlike top-level unique where shapes, relation write selector configs are allowlists for client-provided unique selectors. Do not rely on force() directly inside these selector configs unless your runtime version explicitly documents support for it.

Scope implications

Nested writes bypass the scope extension because Prisma extension hooks only fire for top-level operations. This means:

  • Nested creates do not get scope FK injection — the related record will not have the tenant FK set automatically
  • Nested updates/deletes do not get tenant where conditions — they can affect records across tenants
  • Nested connects reference records by unique fields without tenant filtering

For multi-tenant applications, consider:

  • Using database-level constraints (foreign keys, RLS policies) to enforce tenant boundaries on related models
  • Restricting relation write operations to connect and disconnect only (which reference existing records by ID)
  • Using forced values for tenant FK fields in nested create configs where possible

Logical combinators in where shapes

Where shapes support AND, OR, and NOT to compose filter conditions. The combinator value is a where config defining allowed fields inside the combinator:

await prisma.project
  .guard({
    where: {
      OR: {
        title: { contains: true },
        description: { contains: true },
      },
    },
    take: { max: 50 },
  })
  .findMany({
    where: {
      OR: [
        { title: { contains: 'demo' } },
        { description: { contains: 'demo' } },
      ],
    },
  })

The shape defines which fields are allowed inside each combinator. The client sends arrays for AND/OR and an object or array for NOT.

Forced values inside combinators are lifted to the top-level query as AND conditions, regardless of the combinator type. This means a forced value inside an OR shape does not become an OR branch — it becomes an additional AND constraint on the entire query. This is consistent with the fail-closed design: forced values always restrict, never broaden.

import { force } from 'prisma-guard'

await prisma.project
  .guard({
    where: {
      title: { contains: true },
      NOT: {
        status: { equals: 'archived' },
      },
    },
  })
  .findMany({
    where: { title: { contains: 'demo' } },
  })

status = 'archived' is always excluded regardless of client input.

Combinators can be nested and mixed with scalar fields freely. The same field can appear both at the top level and inside a combinator.

If the same field and operator appear as forced values in different parts of the shape (e.g. top-level and inside an AND combinator), the forced values are conflict-checked. Identical values are deduplicated. Different values throw ShapeError — this prevents ambiguous security configurations from silently degrading.

Combinator validation rules

Combinator definitions in shapes must define at least one field. An empty combinator like where: { AND: {} } throws ShapeError at shape construction time. This prevents silent no-op branches that look restrictive but contribute nothing.

At runtime, combinator arrays from client input must contain at least one element. { AND: [] }, { OR: [] }, and { NOT: [] } are all rejected.

When no forced values exist inside a combinator, each member object must specify at least one condition with a defined value. { AND: [{}] } is rejected because the empty object carries no filtering constraint. This prevents clients from satisfying structural validation while bypassing semantic filtering, which is particularly important for bulk mutations where a vacuous where clause could affect all rows.

When a combinator branch contains forced values, client members may be empty because the forced values still provide meaningful filtering constraints.


Relation filters in where shapes

Where shapes support relation-level filters using Prisma's relation operators. To-many relations support some, every, and none. To-one relations support is and isNot.

await prisma.user
  .guard({
    where: {
      posts: {
        some: {
          title: { contains: true },
          published: { equals: true },
        },
      },
    },
  })
  .findMany({
    where: {
      posts: {
        some: {
          title: { contains: 'guide' },
          published: { equals: true },
        },
      },
    },
  })

Each relation operator value is a nested where config for the related model. All where features — scalar operators, forced values, logical combinators, and nested relation filters — work recursively inside relation filters.

Null existence checks for to-one relations

To-one relation operators support null for testing whether a relation exists:

await prisma.post
  .guard({
    where: {
      author: {
        is: null,     // forced: filter for posts where author IS null
      },
    },
  })
  .findMany(req.body)

In the shape config, null as an operator value is always forced — the client cannot control it. This is the standard Prisma pattern for checking to-one relation existence.

Forced values in relation filters

import { force } from 'prisma-guard'

await prisma.user
  .guard({
    where: {
      posts: {
        some: {
          title: { contains: true },
          status: { equals: 'published' },
        },
      },
    },
  })
  .findMany({
    where: {
      posts: {
        some: { title: { contains: 'guide' } },
      },
    },
  })

status = 'published' is always enforced inside the some operator.

To-one relations

await prisma.post
  .guard({
    where: {
      author: {
        is: {
          role: { equals: true },
        },
      },
    },
  })
  .findMany({
    where: {
      author: {
        is: { role: { equals: 'ADMIN' } },
      },
    },
  })

Combined with logical combinators

await prisma.user
  .guard({
    where: {
      OR: {
        posts: {
          some: { published: { equals: true } },
        },
        profile: {
          is: { bio: { contains: true } },
        },
      },
    },
  })
  .findMany({
    where: {
      OR: [
        { posts: { some: { published: { equals: true } } } },
        { profile: { is: { bio: { contains: 'engineer' } } } },
      ],
    },
  })

Using an unsupported operator for the relation type throws ShapeError. For example, some on a to-one relation or is on a to-many relation is rejected.

Relation filter validation rules

Relation filter definitions in shapes must define at least one operator. An empty relation filter like where: { posts: {} } throws ShapeError at shape construction time.

Each operator's nested where config must define at least one field. An empty nested where like where: { posts: { some: {} } } throws ShapeError at shape construction time.

When all conditions inside a relation operator are client-controlled (no forced values), the client must provide at least one condition. Empty nested where objects are rejected:

where: {
  posts: {
    some: {
      title: { contains: true },
    },
  },
}

// Rejected — at least one condition required
{ where: { posts: { some: {} } } }

// Accepted
{ where: { posts: { some: { title: { contains: 'demo' } } } } }

When a relation operator contains forced values, the client may omit all client-controlled conditions. The forced values are still injected:

where: {
  posts: {
    some: {
      status: { equals: 'published' },
      title: { contains: true },
    },
  },
}

// Accepted — forced status is still applied
{ where: { posts: { some: {} } } }
// Becomes: { posts: { some: { status: { equals: 'published' } } } }

Read projection auto-apply

When a read shape defines select or include, the projection serves two roles: it whitelists what the client is allowed to request, and it provides the default projection when the client omits select/include from the body.

The same default-projection behavior is exposed for planning through resolve(): effectiveReadBody contains the request body plus the synthesized default projection when the client omitted projection.

A client-provided select or include is treated as a narrowing request inside the shape's whitelist. It should not widen back to the full default projection. If the client asks for fewer fields than the shape allows, only the requested allowed fields are returned.

If the client sends a body without select or include, the shape's projection is automatically synthesized and passed to Prisma. This eliminates the need for the client to duplicate the field list that the backend already defines.

await prisma.company
  .guard({
    where: { id: { equals: true } },
    select: {
      id: true,
      name: true,
      description: true,
      posts: {
        select: { id: true, title: true },
        take: { max: 10, default: 5 },
        where: { isDeleted: { equals: false } },
      },
    },
  })
  .findFirst({ where: { id: { equals: 'abc' } } })

The client sends only { where: { id: { equals: 'abc' } } }. The shape's select is applied automatically, nested take defaults and forced where conditions are resolved through the normal pipeline.

If the client does send select or include, the shape acts as a whitelist — only the fields and relations defined in the shape are accepted. When the shape defines a relation with an object config and the client sends relation: true, prisma-guard expands true to the relation's default projection skeleton before validation. This means nested defaults such as take.default, nested whitelists, and forced where rules still apply.

The synthesized projection includes the structural skeleton only: scalar fields as true, nested select/include trees, and object skeletons for relation configs that need nested defaults. Client-controllable args like orderBy, take, skip, and cursor on nested relations are omitted from the synthesized body before parsing; defaults (e.g. take: { default: 5 }) are filled by zod schema parsing, and forced where conditions are merged by the forced tree pipeline. Empty object skeletons that remain empty after parsing collapse back to true.

This applies to all read methods: findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, count, aggregate, and groupBy. Methods where select/include is not valid (aggregate, groupBy) already reject those shape keys upstream, so auto-apply never triggers for them.


Mutation return projection

Mutations that return records can use select and include in the guard shape to control which fields and relations are returned. This uses the same shape syntax as reads — the shape whitelists what the client may request, and forced where conditions on nested includes work identically.

Which methods support projection

Method Returns select/include
create record yes
createMany BatchPayload no
createManyAndReturn record[] yes
update record yes
updateMany BatchPayload no
updateManyAndReturn record[] yes
upsert record yes
delete record yes
deleteMany BatchPayload no

Create with projection

await prisma.project
  .guard({
    data: { title: true },
    include: {
      members: true,
    },
  })
  .create({
    data: { title: 'New project' },
    include: { members: true },
  })

Update with select

await prisma.project
  .guard({
    data: { title: true },
    where: { id: true },
    select: {
      id: true,
      title: true,
      members: {
        select: { id: true, email: true },
      },
    },
  })
  .update({
    data: { title: 'Updated' },
    where: { id: 'abc123' },
    select: {
      id: true,
      title: true,
      members: {
        select: { id: true, email: true },
      },
    },
  })

Delete with include

await prisma.project
  .guard({
    where: { id: true },
    include: { members: true },
  })
  .delete({
    where: { id: 'abc123' },
    include: { members: true },
  })

Forced where on nested includes in mutations

Forced where conditions work the same as in reads. This is useful for ensuring tenant-scoped nested data in mutation responses:

import { force } from 'prisma-guard'

await prisma.project
  .guard({
    data: { title: true },
    include: {
      members: {
        where: { isActive: { equals: force(true) } },
      },
    },
  })
  .create({
    data: { title: 'New project' },
    include: { members: true },
  })

The returned members will always be filtered to isActive = true, regardless of what the client sends.

Mutation projection is optional by default

For mutation methods, if the shape defines select or include but the client omits them from the body, the mutation returns the full record (default Prisma behavior). Mutation projection shapes only validate and constrain client-requested projections unless enforced projection mode is enabled.

This differs from read methods, where the shape's projection is automatically applied as default when the client omits it.

select and include are mutually exclusive

Same as reads: a shape (and a body) cannot define both select and include at the same level. Doing so throws ShapeError.

Batch methods do not support projection

createMany, updateMany, and deleteMany return BatchPayload (a count), not records. Passing select or include in the shape or body for these methods throws ShapeError.


Enforced projection mode

By default, mutation projection shapes only constrain client-requested projections. If the client omits select/include from the mutation body, Prisma returns its default full payload.

When enforceProjection is enabled, mutation shapes' projection is always applied — even when the client does not request one. If the shape defines select or include and the client omits them, prisma-guard synthesizes a projection from the shape and passes it to Prisma.

This setting applies to mutation methods only. Read methods always auto-apply the shape's projection as default when the client omits it — see Read projection auto-apply.

Configuration

generator guard {
  provider           = "prisma-guard"
  output             = "generated/guard"
  enforceProjection  = "true"
}

Behavior

With enforced projection enabled:

await prisma.project
  .guard({
    data: { title: true },
    select: { id: true, title: true },
  })
  .create({ data: { title: 'New' } })

Even though the client omits select from the body, Prisma receives { select: { id: true, title: true } } and returns only those fields.

Without enforced projection (default): Prisma returns all fields.

Synthesized projection

When the client omits select/include, prisma-guard synthesizes a default projection body from the shape:

  • Scalar fields marked true in the shape produce true in the synthesized body
  • Nested relation shapes produce their structural equivalent (nested select/include) or an object skeleton when needed for nested defaults
  • _count configurations are preserved
  • Client-controllable args like where, orderBy, take, skip on nested includes are omitted from the synthesized body before parsing; defaults are then applied by the projection schema, and forced where conditions are applied through the forced-tree pipeline

When the client does provide select/include, behavior is identical regardless of this setting: the client's projection is validated against the shape.

This mode applies to mutation methods that support projection: create, update, upsert, delete, createManyAndReturn, and updateManyAndReturn.


Upsert

Upsert is supported with dedicated create and update shape keys that mirror Prisma's upsert API. The data key is not valid for upsert — use create and update instead.

await prisma.project
  .guard({
    where: { id: true },
    create: { title: true, status: true },
    update: { title: true },
  })
  .upsert({
    where: { id: 'abc123' },
    create: { title: 'New Project', status: 'active' },
    update: { title: 'Updated Title' },
  })

Shape requirements

Upsert shapes must define all three: where, create, and update. Missing any of them throws ShapeError. Using data instead of create/update throws ShapeError.

The create branch follows the same rules as regular create shapes: all required fields without defaults must be accounted for (as client-allowed, forced, scope FK, or @zod .default(...)/@zod .catch(...)). The update branch follows update rules: all fields are optional.

The where must satisfy a unique constraint using Prisma unique selector syntax, same as update and delete. Filter operator objects such as { id: { equals: true } } are rejected in unique where shapes.

All data shape value types work

import { force } from 'prisma-guard'

await prisma.project
  .guard({
    where: { id: true },
    create: {
      title: (base) => base.min(1).max(200),
      status: 'draft',
      isActive: force(true),
    },
    update: {
      title: (base) => base.min(1).max(200),
    },
  })
  .upsert({
    where: { id: 'abc123' },
    create: { title: 'New Project' },
    update: { title: 'Updated' },
  })

Projection support

Upsert returns a record and supports select and include:

await prisma.project
  .guard({
    where: { id: true },
    create: { title: true, status: true },
    update: { title: true },
    select: { id: true, title: true, status: true },
  })
  .upsert({
    where: { id: 'abc123' },
    create: { title: 'New', status: 'active' },
    update: { title: 'Updated' },
    select: { id: true, title: true },
  })

Scope behavior

On scoped models, upsert is fully supported:

  • Scope condition is merged into where using unique-preserving merge (same as update and delete)
  • Scope FK is injected into create data (same as regular creates)
  • Scope FK is stripped from update data (same as regular updates)
  • All scope roots must be present in context — missing roots throw PolicyError

Named shapes and context-dependent shapes

Upsert works with named shapes and context-dependent shapes:

await prisma.project
  .guard({
    '/admin/projects/:id': {
      where: { id: true },
      create: { title: true, status: true, priority: true },
      update: { title: true, status: true, priority: true },
    },
    '/editor/projects/:id': {
      where: { id: true },
      create: { title: true, status: 'draft' },
      update: { title: true },
    },
  }, req.headers['x-caller'])
  .upsert({
    where: { id: req.params.id },
    create: req.body.create,
    update: req.body.update,
  })

Body keys

Upsert accepts: where, create, update, select, include. Unknown keys are rejected with ShapeError.


Named shapes and caller routing

Different pages or API consumers often need different shapes for the same model. Named shapes route requests to the right shape based on a caller value.

Caller is provided as the second argument to .guard() or via the context function — never in the request body. This keeps the method body clean and Prisma-compatible.

Caller keys must not collide with reserved shape config keys (where, data, create, update, include, select, orderBy, etc.). Using a reserved key as a caller path throws ShapeError.

Define named shapes

await prisma.project
  .guard({
    '/admin/projects': {
      where: { title: { contains: true }, status: { equals: true } },
      take: { max: 100 },
    },
    '/public/projects': {
      where: { title: { contains: true } },
      take: { max: 20, default: 10 },
    },
  }, req.headers['x-caller'])
  .findMany(req.body)

The frontend sends its current route as a header:

fetch('/api/projects', {
  headers: { 'x-caller': window.location.pathname },
  body: JSON.stringify({
    where: { title: { contains: 'demo' } },
  }),
})

The backend passes caller as the second argument to .guard(). The request body contains only Prisma-compatible fields.

Named mutation shapes

await prisma.project
  .guard({
    '/admin/projects/:id': {
      data: { title: true, status: true, priority: true },
      where: { id: true },
    },
    '/editor/projects/:id': {
      data: { title: true },
      where: { id: true },
    },
  }, req.headers['x-caller'])
  .update({
    data: req.body.data,
    where: { id: req.params.id },
  })

Named shapes with inline refines

await prisma.project
  .guard({
    '/admin/projects': {
      data: {
        title: (base) => base.min(1).max(500),
        status: true,
      },
    },
    '/public/projects': {
      data: {
        title: (base) => base.min(1).max(100),
      },
    },
  }, req.headers['x-caller'])
  .create({ data: req.body })

All data shape value types (true, literal, force(), function) work in named shapes, context-dependent shapes, and single shapes.

Default fallback

Named shape maps support a default key that acts as a fallback when no caller is provided or no pattern matches:

await prisma.project
  .guard({
    '/admin/projects': {
      where: { title: { contains: true }, status: { equals: true } },
      take: { max: 100 },
    },
    default: {
      where: { title: { contains: true } },
      take: { max: 20 },
    },
  }, req.headers['x-caller'])
  .findMany(req.body)

The default fallback is used when:

  • No caller is provided (missing from both the second argument and context function)
  • The provided caller doesn't match any pattern

Without a default key, missing or unmatched callers throw CallerError.

The default fallback works consistently across both .guard() and guard.query().parse() API surfaces.

Note for generated route configs

The direct runtime API accepts all three guard input forms:

  • one direct guard shape
  • one context-dependent shape function
  • a named shape map

prisma-generator-express mirrors those forms under an operation's shape property. It also provides a variants descriptor API when each named shape needs its own route hooks:

const projectConfig = {
  findMany: {
    before: [authenticate],
    variants: {
      admin: {
        shape: {
          where: { title: { contains: true }, status: { equals: true } },
          take: { max: 100 },
        },
        before: [requireAdmin],
      },
      default: {
        shape: {
          where: { title: { contains: true } },
          take: { max: 20, default: 10 },
        },
      },
    },
  },
}

Within one generated operation, shape and variants cannot both be defined. Both may be omitted when the operation only needs operation-wide hooks or pagination. Each variants[key].shape is one direct shape or one context-dependent shape function; it is not another nested named map.

Use shape when only guard routing is needed. Use variants when hooks need to differ by the matched named shape.

Caller resolution order

Caller is resolved in priority order:

  1. Explicit argument.guard(shapes, '/admin/projects') always wins
  2. Context function — if the context object has a caller string property, it is used as the default
  3. None — if neither source provides a caller and the shape is a named map without a default key, CallerError is thrown

This enables three usage patterns:

// 1. Per-request via context (set once, used everywhere)
guard.extension(() => ({
  Tenant: store.getStore()?.tenantId,
  caller: store.getStore()?.caller,
}))
await prisma.project.guard(shapes).findMany(req.body)

// 2. Explicit override per call
await prisma.project.guard(shapes, '/admin/projects').findMany(req.body)

// 3. Single shape (no caller needed)
await prisma.project.guard({ where: { ... } }).findMany(req.body)

The caller key in the context object is not used for scope injection — it is only used for shape routing. Scope roots are identified by matching context keys against @scope-root model names.

Parameterized caller patterns

/org/:orgId/users
/org/:orgId/users/:userId

Matching is case-sensitive. A parameter segment starts with : and matches exactly one path segment. Segment counts must be equal. Parameters are routing-only and are not extracted into context.

Caller routing uses this precedence:

  1. Reject any named key that collides with a reserved guard shape key.
  2. If caller is not a string, use default when present; otherwise throw the missing-caller CallerError.
  3. If caller is blank or whitespace-only, skip exact and parameterized matching. Use default when present; otherwise throw unknown-caller CallerError.
  4. A non-blank exact key match wins immediately.
  5. If there is no exact match, evaluate parameterized patterns.
  6. One matching pattern selects that declared pattern key.
  7. Multiple matching patterns throw an ambiguous-caller CallerError listing every match.
  8. With no match, use default when present; otherwise throw unknown-caller CallerError.

Exact matching therefore wins over a pattern that would also match:

const shapes = {
  'customer/123': exactShape,
  'customer/:id': parameterizedShape,
}

// Selects "customer/123", not "customer/:id".
await prisma.order.guard(shapes, 'customer/123').findMany()

For a parameterized caller, matchedKey is the declared pattern key. A caller of customer/123 matched by customer/:id produces matchedKey === 'customer/:id'. This is the key used for shape memoization and returned by resolve().

Fail-closed behavior

Named routing fails closed:

  • A non-string caller with no default throws the missing-caller CallerError.
  • A blank or whitespace-only caller never matches a blank key or a parameterized key. It uses default or throws unknown-caller CallerError.
  • An unmatched caller uses default or throws unknown-caller CallerError.
  • Multiple matching parameterized patterns throw ambiguous-caller CallerError.
  • A caller key that collides with a reserved shape key throws ShapeError.

The same precedence and error behavior apply to .guard() and guard.query().parse().

If a request body contains a caller field when using named shapes, it is rejected with a CallerError that directs the developer to use the second argument to .guard() or the context function instead.


Context-dependent shapes

Shapes can be functions that receive the context provided to guard.extension(). This is the same context used for tenant scoping and caller routing — no separate mechanism.

const prisma = new PrismaClient().$extends(
  guard.extension(() => ({
    Tenant: store.getStore()?.tenantId,
    role: store.getStore()?.role,
    caller: store.getStore()?.caller,
  }))
)

The context function returns an object with arbitrary keys. Keys whose values are string, number, or bigint and that match a scope root model name are used as scope context for tenant isolation. The caller key (if a string) is used as the default caller for named shape routing. Other keys (like role in the example above) are passed through to shape functions but are not used for scoping or routing.

The context function should be stable for the duration of a request. It may be read by caller routing, dynamic shape resolution, and scope injection. AsyncLocalStorage is the recommended source for request context.

The context function must return a plain object. If it returns null, undefined, an array, a primitive, or any non-plain-object value, a PolicyError is thrown. This is enforced consistently across all code paths that consume context — scope injection, caller resolution, and dynamic shape evaluation.

If a context key matches a known scope root model name but has a non-primitive value (e.g. an object or array instead of a string, number, or bigint), a PolicyError is thrown immediately. This prevents bugs in the context function from silently weakening scope enforcement.

Dynamic shape functions must return a plain guard shape object. If the function throws or returns a non-object value, a ShapeError is raised.

Single context-dependent shape

await prisma.project
  .guard((ctx) => ({
    where: {
      tenantId: { equals: ctx.Tenant },
      title: { contains: true },
    },
    take: ctx.role === 'admin' ? { max: 100 } : { max: 20 },
  }))
  .findMany(req.body)

Context-dependent data shapes with inline refines

import { force } from 'prisma-guard'

await prisma.project
  .guard((ctx) => ({
    data: {
      title: (base) => base.min(1).max(ctx.role === 'admin' ? 500 : 200),
      status: ctx.role === 'admin' ? true : 'draft',
      isActive: force(true),
    },
  }))
  .create({ data: req.body })

Context-dependent shapes can use the context both for structural decisions (which fields to expose, forced vs client-provided) and within inline refine functions (dynamic validation limits).

Named context-dependent shapes

await prisma.project
  .guard({
    '/admin/projects': (ctx) => ({
      where: {
        tenantId: { equals: ctx.Tenant },
        title: { contains: true },
      },
      take: { max: 100 },
    }),
    '/public/projects': {
      where: { title: { contains: true } },
      take: { max: 20 },
    },
  })
  .findMany(req.body)

Static shapes and function shapes can be mixed freely in the same shape map. In this example, the caller is resolved from contextFn().caller since no explicit caller is passed.


Tenant isolation

Tenant isolation has two layers:

  1. Primary — tenant filters in the shape. Declare them with a context-dependent shape. This is the recommended mechanism for multi-tenant systems:
import { force } from 'prisma-guard'

await prisma.project
  .guard((ctx) => ({
    where: { tenantId: { equals: force(ctx.Tenant) } },
    include: {
      tasks: {
        where: { tenantId: { equals: force(ctx.Tenant) } },
        select: { id: true, title: true },
      },
    },
  }))
  .findMany(req.body)

Tenant filters written in the shape apply at the top level and inside every nested to-many include the shape exposes. They are visible in code review, greppable, and cannot be widened by client input. This is the only mechanism that constrains nested reads — the extension cannot reach them.

  1. Backstop — automatic scope injection. Independently of shapes, the extension injects tenant predicates into top-level scoped queries. It catches unguarded top-level calls, injects scope FKs into creates, and fails closed when tenant context is missing.

Automatic injection covers top-level operations only. Nested reads via include or select and nested writes are not automatically scoped by the extension — constrain them in the shape. See Limitations for details.

Backstop example — the extension rewrites:

await prisma.project
  .guard({ where: { id: { equals: true } } })
  .findFirst({ where: { id: { equals: projectId } } })

Actual enforced condition:

WHERE id = ?
AND tenantId = ?

This applies to all top-level operations on scoped models, including reads, writes, upserts, and deletes.

What the backstop covers (scoped)

  • All top-level reads (findMany, findFirst, findFirstOrThrow, count, aggregate, groupBy)
  • All top-level creates (create, createMany, createManyAndReturn) — scope FK is injected into data
  • All top-level unique mutations (update, delete) — scope condition is merged into where
  • All top-level bulk mutations (updateMany, updateManyAndReturn, deleteMany) — scope condition is merged into where, scope FK is stripped from data
  • upsert — scope condition is merged into where, scope FK is injected into create data, scope FK is stripped from update data

What the backstop does not cover

  • Scope root model delegates themselves — @scope-root marks a context root; it does not self-scope direct calls to that model
  • Nested reads loaded via include or select — use forced where conditions in the shape to restrict these (to-many relations only; see Limitations)
  • Nested writes via relation write configs in data shapes — the scope extension hooks only fire for top-level operations (see Relation writes in data shapes)
  • $queryRaw and $executeRaw — raw SQL bypasses all guard protections

Scope relation writes

When a mutation includes data for a scoped model, the scope extension manages the foreign key field automatically. The onScopeRelationWrite generator config controls what happens if the mutation data also includes the Prisma relation field (e.g. writing tenant: { connect: { id: '...' } } alongside the managed tenantId FK):

Value Behavior
"error" Reject with ShapeError (default)
"warn" Remove the relation field and log a warning
"strip" Remove the relation field silently

This setting is configured in the generator block:

generator guard {
  provider              = "prisma-guard"
  output                = "generated/guard"
  onScopeRelationWrite  = "error"
}

Multi-root scope behavior

A model can be scoped by multiple scope roots simultaneously. If Project has a foreign key to both Tenant and Organization (both marked @scope-root), the scope extension enforces both.

On reads, both scope conditions are combined with AND. If onMissingScopeContext is "warn" or "ignore", only present roots are enforced — missing roots are skipped. If "error" (the default), all roots must be present.

On writes (including upsert), all scope roots must be present in the context. A missing root always throws PolicyError, regardless of onMissingScopeContext.

Scope foreign keys for all present roots are injected into create data and stripped from update/delete data.

If this behavior is not what you want, restructure your schema so the model references only one scope root, or handle scoping explicitly via shape rules.


findUnique behavior

Prisma findUnique and findUniqueOrThrow only accept declared unique selectors.

This is valid Prisma unique selector syntax:

await prisma.project.findUnique({
  where: { id: projectId },
})

For compound unique constraints, Prisma uses a named selector object:

model Project {
  tenantId String
  slug     String

  @@unique([tenantId, slug])
}
await prisma.project.findUnique({
  where: {
    tenantId_slug: {
      tenantId,
      slug,
    },
  },
})

This flat where object is not valid for findUnique unless your Prisma schema declares a matching compound selector with that exact shape:

where: {
  id: projectId,
  tenantId,
}

Because of this, prisma-guard supports two modes.

findUniqueMode = "reject" recommended

Scoped findUnique and findUniqueOrThrow are rejected.

Use findFirst instead:

await prisma.project
  .guard({ where: { id: { equals: true } } })
  .findFirst({ where: { id: { equals: projectId } } })

This allows tenant scope to be enforced at query time.

findUniqueMode = "verify"

The query runs first, then the result is verified against tenant scope.

This is weaker because:

  • it is post-read verification
  • it can require an extra query
  • it has a TOCTOU race window

For tenant isolation, "reject" is the safer production default.

Guard shapes for findUnique and findUniqueOrThrow must define where. A shape without where for these methods throws ShapeError. Unique where shapes must use Prisma unique selector syntax, for example { id: true } or { tenantId_slug: { tenantId: true, slug: true } }.


Output shaping

guard.model() creates output schemas for validating and shaping returned data.

These schemas use base Prisma field types and do not apply @zod input constraints. This is intentional — @zod directives define input validation rules (e.g. .email(), .min(1)) that are not meaningful for validating data already stored in the database.

const userOutput = guard.model('User', {
  pick: ['id', 'email', 'name'],
  include: {
    profile: { pick: ['bio'] },
  },
  strict: true,
})

guard.model() produces a non-strict schema by default, meaning unknown fields in the data are silently stripped from the output. For output validation where unknown fields should be rejected instead of stripped, pass strict: true as shown above.

Notes:

  • pick and omit apply to scalar fields and are mutually exclusive — passing both throws ShapeError
  • relations must be added through include
  • include depth defaults to 5 and can be overridden with maxDepth
  • models may appear more than once in the include tree (e.g. User → posts → author) as long as the total depth does not exceed maxDepth

Strict Decimal mode

By default, Decimal fields accept JavaScript number, decimal string, and Decimal-like objects. Accepting number is convenient but carries a precision risk: floating-point precision may already be lost by the time the validator sees the value (e.g. 0.1 + 0.2 arrives as 0.30000000000000004).

Strict Decimal mode removes number from the accepted types, requiring decimal string or Prisma Decimal objects only.

Configuration

generator guard {
  provider       = "prisma-guard"
  output         = "generated/guard"
  strictDecimal  = "true"
}

Behavior

Mode Accepted types
default number, decimal string, Decimal-like object
strict decimal string, Decimal-like object

With strict mode enabled:

// Accepted
{ price: "29.99" }
{ price: new Prisma.Decimal("29.99") }

// Rejected
{ price: 29.99 }

This applies globally to all Decimal fields across all models, in both data shapes and where filters.

For money or high-precision values, strict mode is recommended. Pass decimal strings (e.g. "0.30") or Prisma Decimal objects instead of JavaScript numbers.


Security model

prisma-guard enforces four layers.

Layer Purpose
Input boundary prevents invalid input
Query boundary restricts allowed query shapes
Tenant filters in shapes primary tenant enforcement — top-level and nested to-many includes the shape exposes
Scope extension automatic backstop for top-level operations

Tenant filters declared in context-dependent shapes are the primary enforcement and cover every level the shape defines. The scope extension backstop covers top-level operations only. Nested reads and writes that the shape does not explicitly constrain are not intercepted — see Limitations.


Limitations

These limitations are real and should be treated as part of the security model.

Raw SQL bypasses guard protections

$queryRaw and $executeRaw are not intercepted.

Scope root models are not self-scoped

@scope-root marks a model as a context root used to scope child models. It does not add a self-scope rule to that root model's own delegate. If you expose operations on the root model itself, protect those routes with explicit guard shapes, application authorization, or database policies.

Nested writes are not scope-intercepted

Prisma extension hooks operate on top-level operations. Relation write configs in data shapes (see Relation writes in data shapes) produce nested write operations that bypass the scope extension entirely. Nested creates do not receive scope FK injection. Nested updates and deletes do not receive tenant where conditions.

For multi-tenant applications using relation writes, enforce tenant boundaries via database constraints (RLS, foreign key constraints, triggers) or application-level validation.

Avoid exposing unconstrained destructive nested writes. For example, a nested deleteMany: {} can delete every child record related to the parent and is not tenant-filtered by the scope extension.

Nested reads via include are not scope-filtered

The scope extension operates on the top-level operation only. If a query uses include or select to load a relation that is itself a scoped model, the nested results are not tenant-filtered by the extension. Use forced where conditions in the include/select shape to restrict nested reads. This applies to both read operations and mutation return projections.

Forced where on nested reads is limited to to-many relations

Prisma does not support where on to-one relation includes. Because of this, forced where conditions in nested include/select shapes only work on to-many relations.

For to-one relations (e.g. author on a Post), the available mitigations are: omit the relation from the include/select shape entirely, restrict which scalar fields are returned using nested select, or rely on database-level constraints (e.g. RLS, foreign key guarantees).

This is a Prisma API constraint, not a prisma-guard limitation.

findUnique cannot be safely scoped in Prisma extension mode

This is a Prisma API limitation, not a conceptual limitation of scoped unique lookups.

That is why findUniqueMode = "reject" is recommended.

Guard shapes for findUnique and findUniqueOrThrow must define where. A shape without where throws ShapeError.

Composite foreign keys to scope roots

If a model references a scope root through composite foreign keys, that specific root is excluded from the model's scope entries when onAmbiguousScope is "warn" or "ignore", and causes a generation error when onAmbiguousScope is "error" (the default). Other non-ambiguous roots on the same model are still auto-scoped.

Handle these models explicitly via shape rules.

Cursor fields must cover a unique constraint

Prisma requires cursor-based pagination to use uniquely-identifiable fields. Guard enforces this at shape construction time: cursor fields must cover at least one unique constraint from the model. Non-unique cursor shapes are rejected with ShapeError.

Compound cursor selectors use the same Prisma selector syntax as compound unique where values:

cursor: {
  tenantId_slug: {
    tenantId: true,
    slug: true,
  },
}

@zod on list fields applies to the array

@zod directives on list fields (e.g. String[]) apply to the z.array(...) schema, not to individual elements. For example, .min(1) on a String[] field enforces a minimum array length of 1, not a minimum string length per element.

Batch methods do not support return projection

createMany, updateMany, and deleteMany return BatchPayload (a count). Passing select or include in the shape or body for these methods throws ShapeError.

Generated output is TypeScript with configurable imports

The generator writes TypeScript files. Internal generated imports are controlled by importStyle. Auto mode reads the consuming project's tsconfig.json and package type, then emits extensionless, .js, or .ts imports as appropriate. CommonJS/classic projects normally use extensionless imports; NodeNext/Node16 ESM projects normally use .js imports. If auto-detection is wrong for your build pipeline, set importStyle explicitly.

having supports logical combinators

Guard having shapes support AND, OR, and NOT combinators for composing complex grouped aggregation filters. The fields available inside combinators are the same fields defined in the having shape config.

Json fields accept any JSON-serializable value

Json fields are recursively validated as JSON-serializable values (string, number, boolean, null, plain objects, arrays). Values that are not JSON-serializable — including undefined, functions, symbols, class instances (such as Date), NaN, Infinity, and circular references — are rejected. This does not enforce any particular JSON structure. If you need structured JSON validation, use a context-dependent shape or validate before calling guard.

refine and inline refine functions replace @zod chains

When a refine callback is provided for a field in guard.input(), or when a function is used instead of true in a data/create/update shape, the callback receives the base Zod type without @zod chains. The @zod directive for that field is bypassed entirely. See Schema annotations.

If the refine function returns a schema that handles undefined input (produces a non-undefined value for undefined), prisma-guard detects this and preserves the behavior by not wrapping with .optional() in create mode.

pick and omit are mutually exclusive

Both guard.input() and guard.model() reject configurations that specify both pick and omit. This is enforced at both the type level and at runtime.

@zod field modifiers interact with prisma-guard nullability

Using @zod .optional(), .nullable(), or .nullish() applies the Zod method on top of prisma-guard's own nullability/optionality handling. This can cause double-wrapping. These modifiers are available but should only be used when intentionally overriding default behavior. Exception: when a chain contains .default() or .catch(), prisma-guard skips adding .optional() in create mode to preserve the default/catch behavior.

Inline refine functions are not cached

Data schemas containing inline refine functions are rebuilt on every request, since the function reference could be context-dependent (e.g. when used inside a dynamic shape that closes over context values). Static data shapes using only true and literal values are cached normally.

take does not support negative values

Prisma supports negative take for reverse cursor pagination. prisma-guard restricts take to positive integers (minimum 1). If you need reverse pagination, construct the query server-side using a context-dependent shape.

skip in shape config is a permission flag

skip: true in a shape config means the client is allowed to provide a skip value. The actual skip value must be a non-negative integer. The value must be exactly true — other truthy values are rejected with ShapeError. This is consistent with other shape flags but differs from take, which uses { max, default? } syntax or a number shorthand.

guard.input() defaults to allowing null for nullable fields

guard.input() defaults allowNull to true, matching the behavior of .guard({ data: ... }) and Prisma's own nullable field handling. Pass allowNull: false to reject null values for nullable fields.

Decimal fields accept JavaScript numbers by default

The Decimal base type accepts JavaScript number, decimal string, and Decimal-like objects by default. Accepting number is convenient but carries a precision risk: by the time the validator sees the value, floating-point precision may already be lost. For example, 0.1 + 0.2 arrives as 0.30000000000000004. For money or high-precision values, enable strict Decimal mode or pass decimal strings (e.g. "0.30") or Prisma Decimal objects instead of JavaScript numbers.

skipDuplicates is supported for batch create methods

createMany and createManyAndReturn accept skipDuplicates: boolean in the request body. This is passed through to Prisma without shape-level configuration. It is not available on create.

Conflicting forced where values are rejected

If the same field and operator appear as forced values in different parts of a where shape (e.g. at the top level and inside an AND combinator) with different values, the shape is rejected with ShapeError at construction time. Identical duplicate forced values are deduplicated silently. This prevents ambiguous security configurations from silently degrading.

Mutation projection shapes do not enforce a fixed output boundary by default

If a mutation shape defines select or include but the client omits them from the body, Prisma returns its default full payload. Mutation projection shapes only validate and constrain client-requested projections. Enable enforced projection mode to always apply the shape's projection. This limitation applies to mutations only — read methods always auto-apply the shape's projection as default.

create and update are reserved shape keys

The bare words create and update cannot be used as caller keys in named shape routing, as they are reserved for upsert shape configuration. Full paths like '/admin/create' or '/api/users/update' are unaffected — only the bare words collide.

Empty select and include shapes are rejected

An empty select: {} or include: {} in a guard shape throws ShapeError at shape construction time. This is consistent with the fail-closed design applied to empty combinators, empty relation filters, and empty operator objects.

Shape config values must be exactly true

Fields in orderBy, cursor, having, _count (object form), _avg, _sum, _min, _max config objects must have the value true. The skip config must be exactly true. Passing false, numbers, strings, or any other value throws ShapeError. This prevents misconfiguration where false is silently treated as enabled.

@zod .catch() fields are tracked alongside .default() fields

Both @zod .default(...) and @zod .catch(...) are tracked in the generated ZOD_DEFAULTS map. Fields with either directive are exempted from create completeness checks and auto-injected as forced values when omitted from data shapes. The .catch() behavior (fallback on parse error) is preserved in create mode by not wrapping the schema with .optional().

mode modifier in where filters

Prisma's mode modifier for case-insensitive string filtering is supported on String fields (with contains, startsWith, endsWith, equals) and Json fields (with string_contains, string_starts_with, string_ends_with). The shape syntax is { field: { contains: true, mode: true } } for client-controlled mode or { field: { contains: true, mode: 'insensitive' } } for forced mode.

When mode is forced, prisma-guard inlines the forced mode value into the same operator object as the client-provided string operator, producing { field: { contains: 'foo', mode: 'insensitive' } } in the final query. This co-location is necessary because Prisma's mode is a modifier on StringFilter — a sibling AND clause carrying only mode would have no operator to modify and would be silently ignored.

The same inline-merge behavior applies to any other forced operator on a field where the client provides a different operator on the same field. Forced operators that conflict with the client's value on the same op key (e.g. forced { equals: 'x' } plus client { equals: 'y' }) are rejected with ShapeError at merge time. Forced conditions on fields the client did not touch fall back to AND-wrapping, unchanged from previous behavior.

A shape with { field: { mode: true } } alone (no compatible string operator) is rejected with ShapeError.


Advanced: SQL-backed runtimes

The findUnique limitation exists in Prisma Client extension mode because Prisma requires a unique selector input type.

At the SQL level, scoped unique lookups are straightforward:

SELECT *
FROM "Project"
WHERE "id" = $1
  AND "tenantId" = $2
LIMIT 1

If your runtime controls SQL generation directly, it can enforce unique lookup plus tenant predicate in a single query.

Libraries like prisma-sql make this possible for advanced architectures.


Error handling

.guard(shape).method(body) may throw:

  • ShapeError — invalid shape config, unknown shape config keys, wrong method for shape, body format issues, unexpected body keys, incomplete top-level create data shapes, invalid inline refine functions, dynamic shape functions returning invalid values, conflicting forced where values, empty combinator or relation filter definitions, empty projection shapes, vacuous combinator input, non-true config values in shape builders, Zod validation failures caught by guarded method parsing, or using data instead of create/update for upsert
  • CallerError — missing, unknown, or ambiguous caller in named shapes, or caller found in request body
  • PolicyError — denied scope, missing tenant context, invalid context function return value, invalid scope root value type, or rejected operations on scoped models (e.g. findUnique in reject mode)
  • ZodError — raw Zod validation failures from lower-level APIs such as guard.input().parse() and guard.query().parse() when wrapZodErrors is not enabled

All guard errors include status and code properties for HTTP response mapping:

Error status code
ShapeError 400 SHAPE_INVALID
CallerError 400 CALLER_UNKNOWN
PolicyError 403 POLICY_DENIED

Note: Prisma errors (PrismaClientKnownRequestError, PrismaClientValidationError, etc.) propagate through the guard layer unmodified. Error handlers should be prepared to handle both guard errors (with status/code properties) and Prisma errors.

ZodError wrapping

By default, lower-level parsing APIs can expose raw ZodError. Most .guard(shape).method(body) validation failures are reported as ShapeError, but code that calls guard.input().parse() or guard.query().parse() directly should still handle raw Zod errors unless wrapping is enabled.

To unify error handling, pass wrapZodErrors: true in the guard config:

const guard = createGuard({
  ...generatedConfig,
  wrapZodErrors: true,
})

When enabled, ZodError thrown during supported guard validation paths is caught and rethrown as ShapeError with status: 400 and code: 'SHAPE_INVALID'. The original ZodError is preserved as the cause property. The error message includes a formatted summary of all Zod issues.

This applies to guard.input().parse(), guard.query().parse(), and guarded model methods. guard.model() returns a raw z.ZodObject and is not affected.


How it works internally

prisma-guard has two main parts.

1. Generator

Runs during prisma generate. Requires zod and @prisma/client to be installed.

It reads the Prisma DMMF and emits:

  • TYPE_MAP — field metadata per model
  • ENUM_MAP — enum values
  • SCOPE_MAP — foreign key → scope root mappings
  • ZOD_CHAINS@zod directive chains (validated for syntax, method allowlist, argument arity, and schema construction against the generated base type. Argument type mismatches are caught when Zod throws during schema construction; otherwise they may fail later when the schema is used.)
  • ZOD_DEFAULTS — per-model list of fields that have @zod .default(...) or @zod .catch(...), used by the create completeness check and by runtime default injection for omitted fields
  • GUARD_CONFIG — generator config values (including strictDecimal and enforceProjection)
  • UNIQUE_MAP — unique constraint metadata per model
  • client.ts — pre-wired guard instance with typed model extensions

2. Runtime

At runtime, guard.extension() creates a Prisma extension that provides:

  • .guard(shape, caller?) on every model delegate — validates input, enforces query shapes, returns typed Prisma methods
  • $allOperations query hook — injects tenant scope into every top-level database operation

The .guard() call validates against the shape, merges forced values, and delegates to the underlying Prisma method. The scope layer runs transparently underneath.

For read methods, when the shape defines select or include and the client body omits them, the shape's projection is automatically synthesized and passed to Prisma. The synthesized body includes the structural skeleton only (scalar fields as true, nested select/include trees). Client-controllable args on nested relations are omitted — defaults are filled by zod schema parsing and forced where conditions are merged by the forced tree pipeline. This ensures the shape defines both the security boundary and the default response shape in a single declaration.

For create operations, fields tracked in ZOD_DEFAULTS that are omitted from the data shape are auto-injected as forced values. The runtime evaluates the field's Zod schema with undefined input and uses the resulting value. This ensures @zod .default(...) and @zod .catch(...) produce correct data even when the field is not listed in the shape.

For fields that ARE listed in the data shape with true and have @zod .default(...) or @zod .catch(...), the runtime skips wrapping the schema with .optional() in create mode. This preserves the Zod default/catch behavior: omitting the field from client input triggers the default rather than passing undefined through.

Caller routing is resolved before method execution: the explicit caller argument takes priority, then contextFn().caller, then absent (which is fine for single shapes but throws CallerError for named shape maps without a default key).

The context function is validated on every code path that consumes it — scope injection, caller resolution, and dynamic shape evaluation all enforce the plain-object contract and throw PolicyError for invalid returns. Additionally, if a context key matches a known scope root but has a non-primitive value, PolicyError is thrown immediately rather than silently dropping the scope.

Forced where merge strategy

When a where shape includes forced conditions, prisma-guard merges them into the client's validated where in two ways:

  1. Inline merge — if a forced field's value is a plain operator object and the client also provided an operator object for the same field, the forced operator keys are merged into the client's operator object. This is required for modifiers like mode that must co-locate with the operator they modify. Conflicts on the same op key (different values) throw ShapeError.
  2. AND-wrap — forced fields not present in the client's where, or where the value types don't allow inline merging, are placed in a separate AND branch.

If all forced fields inline successfully, the result is a flat object with no synthetic AND wrapper. Forced conditions inside combinators are still lifted to top-level AND constraints, following the same merge logic. Forced NOT is special-cased: client NOT and forced NOT are kept as separate logical branches so arrays and objects preserve Prisma NOT semantics.

The force() helper

The force() function is exported from prisma-guard and creates a wrapper object with an internal symbol marker. At runtime, shape processing checks for this marker to distinguish forced values from the true sentinel. The wrapper is unwrapped before Zod validation, so the forced value is validated against the field's schema like any other literal. The symbol is not enumerable and does not interfere with serialization or inspection of shape objects.


Why this approach

Common alternatives have tradeoffs.

Approach Tradeoff
ad hoc route validation repetitive and inconsistent
middleware-only filtering too easy to miss edge cases
runtime reflection-heavy systems slower and harder to reason about
full ORM replacement larger migration cost

prisma-guard focuses narrowly on data boundaries, not ORM replacement.


Performance characteristics

The runtime does lightweight argument rewriting and Zod validation.

In most real applications, overhead should be negligible relative to database round-trip time.

guard.query() caching: Static shapes passed to guard.query() are cached for the lifetime of the returned QuerySchema object. In a named shape map, each static entry is cached independently — a map with 9 static entries and 1 context-dependent entry will cache the 9 static entries. Context-dependent shapes (functions) resolve the function on each call because they depend on runtime context and are never cached.

.guard() caching: The .guard(shape).method(body) chain creates per-invocation caches. In typical usage, the cache is created, used for one method call, and discarded. If you store the result of .guard(shape) and call multiple methods on it, the cache is shared across those calls. For hot paths where the same static shape is used repeatedly, prefer guard.query() for persistent caching.

Data schemas containing inline refine functions are also not cached, since the function could close over runtime context values. Data shapes using only true and literal values are cached normally within their invocation scope. Projection schemas (select/include on mutations) are cached independently for static shapes within their invocation scope.

For upsert, create and update data schemas are cached independently under namespaced keys (upsert:create, upsert:update) to avoid cache collisions with regular create/update operations.


Security philosophy

prisma-guard is designed to fail closed.

Condition Behavior
ambiguous scope mapping error by default
missing scope context error by default
invalid scope root value type error always
onMissingScopeContext = "ignore" scope bypassed for missing roots; present roots still enforced
unsafe scoped findUnique reject recommended
invalid @zod directive error by default
missing caller in named shapes error unless default key exists
caller in request body error always
data in read shape error always
data in upsert shape error always (use create/update)
missing create or update in upsert error always
missing data in write shape error always
bulk mutation without where shape error always
bulk mutation with empty where error always
vacuous combinator input error always
empty combinator/relation shape branch error always
empty select/include shape error always
unexpected keys in mutation body error always
unknown keys in shape config error always
non-true values in shape config error always
cursor not covering unique constraint error always
caller key collides with shape config error always
empty operator objects in where error always
empty relation filter (no forced) error always
empty relation operator container error always
empty relation filter shape definition error always
pick and omit both specified error always
scope relation in mutation data controlled by onScopeRelationWrite (default: error)
incomplete create data shape error always
invalid inline refine function error always (ShapeError)
projection on batch method error always
body projection without shape error always
dynamic shape returns non-object error always (ShapeError)
refine callback returns non-Zod schema error always (ShapeError)
findUnique shape without where error always (ShapeError)
invalid relation operator for type error always (ShapeError)
context function returns non-object error always (PolicyError)
conflicting forced where values error always (ShapeError)
client echoes forced-only where field stripped before validation
client NOT plus forced NOT preserved as separate logical branches
invalid context function return error always (PolicyError)
mode modifier without compatible op error always (ShapeError)
forced operator conflicts with client value error always (ShapeError)
@zod .default()/.catch() field omitted from shape auto-injected as forced value
read shape with select/include, client omits auto-applied as default projection
mutation shape with select/include, client omits full payload unless enforceProjection enabled
nested writes via relation configs bypass scope extension (top-level only)

Recommended production configuration

generator guard {
  provider                = "prisma-guard"
  output                  = "generated/guard"
  onInvalidZod            = "error"
  onAmbiguousScope        = "error"
  onMissingScopeContext   = "error"
  findUniqueMode          = "reject"
  onScopeRelationWrite    = "error"
  strictDecimal           = "true"
  enforceProjection       = "true"
  importStyle             = "auto"
  runtimeImportPath       = "prisma-guard"
}

When to use prisma-guard

Best fit:

  • multi-tenant SaaS backends that want explicit, reviewable tenant filters in shapes
  • Prisma-based microservices
  • RPC / internal API backends
  • systems that want schema-driven validation and scoping

Less suitable:

  • raw SQL-heavy systems
  • architectures that bypass Prisma Client
  • cases where another layer already owns validation and authorization comprehensively

Version compatibility

Supported Prisma versions:

Prisma 6
Prisma 7

Supported Node versions:

Node 20
Node 22

Design principles

  1. Fail closed on ambiguous security conditions
  2. Prefer query-time enforcement over verification
  3. Tenant enforcement belongs in the shape — context-dependent shapes are the primary mechanism; automatic scope injection is a backstop
  4. Generate minimal runtime metadata
  5. Avoid automatic relation traversal
  6. Keep scope rules explicit and schema-driven
  7. One chain — shape defines the boundary, method executes
  8. Method bodies stay Prisma-compatible — routing and context live in .guard()
  9. No overloaded sentinel values — true always means client-controlled, force() for forced booleans
  10. Upsert uses create/update keys, not data — matches Prisma's own API shape
  11. Shape config values are validated strictly — true means enabled, anything else is rejected
  12. Read shapes with projection define both the security boundary and the default response — no client duplication needed
  13. Nested writes are validated but not scope-intercepted — constrain them with shape-level tenant filters or database constraints

Comparison

Feature prisma-guard raw Prisma
Input validation yes no
Query shape enforcement yes no
Tenant filters via context-dependent shapes yes manual
Nested read tenant filtering (to-many, via shapes) yes manual
Automatic scope injection backstop (top-level) yes no
Safe scoped findUnique in extension mode reject not handled
Schema-driven rules yes no
Caller-based shape routing yes no
Typed method chaining yes n/a
Bulk mutation safety required where not handled
Vacuous combinator rejection yes not handled
Mutation body validation strict keys no
Shape config validation strict values n/a
Create completeness validation yes no
Mutation return projection yes manual
Enforced projection mode opt-in no
Read projection auto-apply yes no
Upsert support yes manual
Inline field refine in data shapes yes n/a
ZodError wrapping opt-in n/a
Logical combinators in where yes manual
Relation filters in where yes manual
Relation writes in data shapes yes manual
Empty relation filter rejection yes n/a
Empty projection shape rejection yes n/a
Forced where conflict detection yes n/a
Forced boolean values via force() yes n/a
Case-insensitive string filtering (mode) yes manual
Forced operator inline merge yes n/a
Strict Decimal mode opt-in n/a
@zod .default()/.catch() auto-injection yes n/a
Nested write scope enforcement no (declare tenant filters in shapes instead) no

Roadmap

Possible future improvements:

  • richer relation-level policies
  • nested write scope enforcement helpers
  • adapter integrations for SQL-backed runtimes
  • model-specific generated types for stronger compile-time shape validation
  • structured JSON field validation via schema annotations

License

MIT

About

Define Prisma data boundaries once with generated validation, reusable query and write shapes, projections, caller variants, and tenant scope.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages