---
name: stash-prisma
description: Integrate CipherStash searchable field-level encryption with Prisma Next using @cipherstash/stack-prisma (EQL v3). Covers the full 31-constructor catalog of domain-named encrypted column types in schema.prisma (per plaintext type × capability tier — Text/TextEq/TextOrd/TextMatch/TextSearch, Integer/Smallint/BigInt/Numeric/Real/Double × Eq/Ord, Date/Timestamp × Eq/Ord, Boolean, Json), the one-call cipherstashFromStack wiring, the runtime value envelopes (EncryptedString/Number/BigInt/Date/Boolean/Json) and decryptAll, the eql* query operators (eqlEq, eqlMatch, eqlGt, eqlBetween, eqlIn, eqlJsonContains, eqlAsc/eqlDesc, eqlJsonPathAsc/eqlJsonPathDesc), EQL bundle installation via prisma-next migrate, and authentication. Use when adding encryption to a Prisma Next project, upgrading @cipherstash/stack-prisma, choosing a column type, or querying encrypted columns.
---

# CipherStash Stack — Prisma Next Integration

Guide for searchable field-level encryption in a **Prisma Next** app with
`@cipherstash/stack-prisma` (EQL v3), powered by `@cipherstash/stack`. You declare
encrypted columns directly in `schema.prisma`; Prisma Next's migration system
installs the EQL bundle in the same sweep that creates your tables — there is no
separate `stash eql install` step.

> `@cipherstash/stack-prisma` is **EQL v3 only** — there is no EQL v2 surface.
> Everything below is v3.

In EQL v3 every encrypted column is a **concrete Postgres domain**
(`public.eql_v3_text_search`, `public.eql_v3_double_ord`, …) whose query
capabilities are fixed by the column type you choose — there is no capability
config object. See the `stash-encryption` skill for the domain catalog and
capability semantics; this skill covers the Prisma-Next-specific surface.

## When to Use This Skill

- Adding field-level encryption to a Prisma Next project
- Declaring encrypted columns in `schema.prisma`
- Querying encrypted columns with the `eql*` operators
- Wiring the runtime with `cipherstashFromStack`

## Installation

```bash
npm install @cipherstash/stack @cipherstash/stack-prisma
```

Or run `npx stash init --prisma`, which detects Prisma Next, installs both
packages pinned to the CLI release, and authenticates. It does **not** scaffold
the wiring files — Prisma Next derives its schema from `contract.json`, so there
is no encryption-client file to generate; init prints the next steps (declare
encrypted columns, emit the contract, run the migration) instead.

## The three wiring points

### 1. Declare encrypted columns in `schema.prisma`

The column types are **domain-named** — the name encodes the query capability
(matching the `@cipherstash/stack` `types.*` catalog), not a generic primitive:

```prisma
model User {
  id            String @id
  email         cipherstash.TextSearch()  // eq + range + free-text + ORDER BY
  salary        cipherstash.DoubleOrd()   // eq + range + ORDER BY
  accountId     cipherstash.BigIntOrd()   // eq + range + ORDER BY
  birthday      cipherstash.DateOrd()     // eq + range + ORDER BY
  emailVerified cipherstash.Boolean()     // storage-only (no operators)
  preferences   cipherstash.Json()        // containment (@>)
}
```

The example shows six types; the **full catalog is 31 constructors** — one
per exposed `public.eql_v3_*` domain, derived mechanically from the domain
registry. Pick by **plaintext TypeScript type first**, then by the queries
you need:

| Plaintext (TS type) | Storage-only | Equality | Order + range | Free-text | Everything |
|---|---|---|---|---|---|
| `string` | `Text()` | `TextEq()` | `TextOrd()` | `TextMatch()` | `TextSearch()` |
| `number` (int4) | `Integer()` | `IntegerEq()` | `IntegerOrd()` | — | — |
| `number` (int2) | `Smallint()` | `SmallintEq()` | `SmallintOrd()` | — | — |
| `bigint` (int8) | `BigInt()` | `BigIntEq()` | `BigIntOrd()` | — | — |
| `number` (numeric) | `Numeric()` | `NumericEq()` | `NumericOrd()` | — | — |
| `number` (float4) | `Real()` | `RealEq()` | `RealOrd()` | — | — |
| `number` (float8) | `Double()` | `DoubleEq()` | `DoubleOrd()` | — | — |
| `Date` (date) | `Date()` | `DateEq()` | `DateOrd()` | — | — |
| `Date` (timestamp) | `Timestamp()` | `TimestampEq()` | `TimestampOrd()` | — | — |
| `boolean` | `Boolean()` | — | — | — | — |
| JSON document | `Json()` — searchable JSON: containment + JSONPath equality/range/ORDER BY | | | | |

Reading the table:

- Each constructor maps 1:1 to the domain named after it:
  `IntegerOrd()` → `eql_v3_integer_ord`, `Text()` → `eql_v3_text`, and so on
  (`Json()` → `eql_v3_json_search`).
- **Every `*Ord` domain includes equality** (equality + range + ORDER BY);
  every `*Eq` domain is equality only; the bare family name is storage-only
  (encrypt/decrypt, no operators). `TextMatch` is free-text **only** — no
  equality. `TextSearch` carries all three text capabilities.
- **The plaintext type matters as much as the capability.** Money stored as
  integer cents wants `IntegerOrd()` (JS `number`) — not `DoubleOrd()`
  (float semantics) and not `BigIntOrd()`, whose plaintext is a JS `bigint`
  and rejects `number` values.
- The `*OrdOre` variants exist in the database bundle but are deliberately
  not exposed as constructors (their btree opclass is superuser-gated — see
  `stash-indexing`).

The type is fixed at the column — there is no capability tuner. A value you
only store and decrypt can use a storage-only domain; a value you filter or
sort needs the matching `*Eq` / `*Ord` / text-search domain.

### 2. Register the extension pack in `prisma-next.config.ts`

Since Prisma Next 0.17 an application depends on exactly one database facade
(`@prisma/orm-postgres`; the retired `@prisma-next/*` scope no longer
publishes), and the facade's `defineConfig` wires the family, target, adapter,
driver, and PSL provider internally:

```typescript
import cipherstash from '@cipherstash/stack-prisma/control'
import { defineConfig } from '@prisma/orm-postgres/config'

export default defineConfig({
  contract: './prisma/schema.prisma',
  output: 'src/prisma',
  extensions: [cipherstash],
  db: { connection: process.env['DATABASE_URL']! },
})
```

The config key is `extensions` (0.17 renamed `extensionPacks`; the old key
fails loudly).

### 3. Wire the runtime with `cipherstashFromStack` in `src/db.ts`

```typescript
import 'dotenv/config'
import { cipherstashFromStack } from '@cipherstash/stack-prisma/v3'
import postgres from '@prisma/orm-postgres/runtime'
import type { Contract } from './prisma/contract.d'
import contractJson from './prisma/contract.json' with { type: 'json' }

const cipherstash = await cipherstashFromStack({ contractJson })

export const db = postgres<Contract>({
  contractJson,
  extensions: cipherstash.extensions,
  middleware: cipherstash.middleware,
})
```

`cipherstashFromStack({ contractJson })` derives the v3 encryption schemas from
the contract (one `public.eql_v3_*` domain per column), constructs the
`@cipherstash/stack` `Encryption` client from your `CS_*` env vars or local
profile, builds the SDK adapter, and returns ready-to-spread `extensions` and
`middleware`.

## Install the EQL bundle (part of your migration, not a separate step)

The extension pack contributes its own contract space at
`migrations/cipherstash/`, so the EQL bundle installs alongside your application
schema:

```bash
npx stash auth login                 # one-time, per developer
npx prisma-next contract emit
npx prisma-next migration plan --name initial
npx prisma-next migrate              # installs EQL bundle + your schema
```

The apply command is the top-level `prisma-next migrate` (add `--yes` to skip the
confirmation prompt in CI). There is no `prisma-next migration apply` subcommand.

Do **not** run `stash eql install` for a Prisma Next project — `prisma-next
migrate` owns EQL installation, and `stash init --prisma` skips the
standalone installer for exactly this reason. The CLI enforces this: `stash eql
install` detects a Prisma Next project and refuses (pointing you at `prisma-next
migrate`) unless you pass `--force`.

### After upgrading `@cipherstash/stack-prisma`, re-plan before anything else

Only `prisma-next migration plan` copies migration packages into your repo, and
the seed phase never rewrites a directory that already exists. A
`migrations/cipherstash/` generated against an older version therefore keeps that
version's EQL bundle forever — it is old, not corrupt, so it passes every
integrity check and nothing reports a problem.

**After upgrading the package, delete the vendored directory and regenerate it:**

```bash
rm -rf migrations/cipherstash
npx prisma-next migration plan
```

The database is untouched by this: markers are keyed by invariant, so
already-applied invariants do not re-run and the only new work is the upgrade
edges.

Skipping it is not always fatal, which is what makes it easy to miss. 1.0.0
and 1.1.x shipped the baseline at **eql-3.0.4**, and 1.2.x shipped it at
**eql-3.0.6**; later versions bake **eql-3.1.0** into the same baseline directory
(`20260601T0100_install_eql_v3_bundle`), so its bytes and its `migrationHash`
changed:

| You run | With a stale `migrations/cipherstash/` |
| --- | --- |
| `prisma-next migration plan` | Succeeds, silently keeping the stale baseline — no hash mismatch, because it is intact, just old. |
| `prisma-next migrate` (existing database) | Correct: applies only the upgrade edges the database has not walked. A 1.2.x database walks the 3.1.0 edge; a 1.0.0 or 1.1.x one walks 3.0.5, 3.0.6 and 3.1.0 (the bundle is re-installed three times). |
| `prisma-next migrate` (fresh database) | Correct end state, but installs the stale baseline's bundle and then immediately re-installs each newer one over it. |
| `prisma-next db init` (fresh database) | **Fails.** From a 1.0.0 or 1.1.x space: `Operation cipherstash.upgrade-eql-v3-bundle-3.0.5 has class "data" which is not allowed by policy.` From a 1.2.x space: `Operation cipherstash.upgrade-eql-v3-bundle-3.1.0 has class "data" which is not allowed by policy.` `db init` is additive-only and the stale baseline does not carry the newer invariants, so the planner has to reach for the data-classed upgrade edges. The message does not say any of that — the remedy is the `rm -rf` above. |

Upgrading and then running `migrate` or `db init` **without** planning first
leaves the newer bundle off disk entirely, so it is silently skipped and the
database stays on the older one.

## Encrypting data that already exists (`stash encrypt`)

Declaring an encrypted column only covers new writes. To encrypt rows already in
a plaintext column, use the CLI's rollout lifecycle — `stash encrypt backfill`,
then switch reads, then `stash encrypt drop` (`stash-cli` and `stash-encryption`
are canonical for the sequence and its dual-write precondition).

Two things are Prisma-Next-specific:

- **No encryption client file is needed.** `stash.config.ts`'s `client` option
  points at a file this integration deliberately doesn't have. `stash encrypt
  backfill` — the only command that loads it — detects a Prisma Next project,
  reads the emitted `contract.json` (`src/prisma/`, `prisma/`, or the project
  root), and derives the schemas the same way the runtime does. So run
  `prisma-next contract emit` before `stash encrypt backfill`, and don't
  hand-author a bridge client file.
- **The tracking schema is created for you.** `cipherstash.cs_migrations` is
  normally created by `stash eql install`, which this integration never runs.
  `stash encrypt backfill` bootstraps it itself (idempotently), so the backfill
  user needs CREATE on the database the first time.

## Indexing encrypted columns

The adapter emits the encrypted query operators, but **no index DDL** — without
functional indexes over the `eql_v3.*` extractors, every encrypted predicate
sequential-scans. Since Prisma Next 0.17, `@@index` takes an `expression`
argument, so the indexes are declared in `schema.prisma` next to the columns
they serve and ride the same `prisma-next migration plan` / `prisma-next
migrate` flow as everything else. Never run index DDL out-of-band.

One index per capability the column's domain carries:

```prisma
model User {
  // ... fields, including the encrypted columns ...

  // cipherstash.TextEq / TextSearch: equality
  @@index(expression: "eql_v3.eq_term(email)", name: "users_email_eq", type: "btree")
  // cipherstash.*Ord / TextSearch: ordering + range (on numeric/date/timestamp
  // _ord domains this one index serves = too; TextOrd needs the eq_term index
  // above as well)
  @@index(expression: "eql_v3.ord_term(created_at)", name: "users_created_at_ord", type: "btree")
  // cipherstash.TextMatch / TextSearch: free-text match
  @@index(expression: "eql_v3.match_term(bio)", name: "users_bio_match", type: "gin")
  // cipherstash.Json: containment
  @@index(expression: "(eql_v3.to_ste_vec_query(profile)::jsonb) jsonb_path_ops", name: "users_profile_json", type: "gin")
}
```

Three rules the interpreter enforces: an `@@index` takes exactly one of a
fields list or an `expression`; an expression index **requires `name` or
`map`** (no default name can be derived from an expression); and an `options`
argument requires `type`. The expression string is the entire element list
between the parens of `CREATE INDEX`, inserted verbatim — which is why the
Json recipe carries its own parens and the `jsonb_path_ops` opclass. TS-authored
contracts have the same surface: `index({ expression, name })` alongside the
column factories — and there, passing `type` makes `options` required (use
`options: {}` when you have none).

`name:` is a logical name, not the physical one: the index is created as
`<name>_<8-hex content hash>` (`users_email_eq` lands as e.g.
`users_email_eq_1a2b3c4d`), so when verifying with `EXPLAIN` or querying
`pg_indexes`, match on the prefix rather than the exact string. `map:` pins
the exact physical name instead — but the planner emits a drift warning
whenever `map:` is combined with an expression body, so prefer `name:` and
prefix-matching unless you must adopt an index that already exists under a
bare name.

`ANALYZE` is still part of the recipe — an expression index has no statistics
until it runs, and PSL cannot express it — so it rides a raw-SQL operation
(`rawSql` from `@prisma/orm-postgres/migration`) in the migration that
introduces the indexes:

```typescript
rawSql({
  id: 'analyze.users',
  label: 'Refresh statistics for the new expression indexes',
  operationClass: 'additive',
  target: {
    id: 'postgres',
    details: { schema: 'public', objectType: 'table', name: 'users' },
  },
  precheck: [],
  execute: [{ description: 'refresh statistics', sql: 'ANALYZE "public"."users"' }],
  postcheck: [],
})
```

`rawSql` also remains the fallback for index DDL PSL doesn't carry — with one
hard exception: **`CREATE INDEX CONCURRENTLY` cannot run through the migration
flow at all.** The runner wraps every apply in a single transaction, and
Postgres rejects `CONCURRENTLY` inside a transaction block (error `25001`), so
a `rawSql` operation carrying it fails deterministically. This rarely matters
here: under the rollout timing in `stash-indexing`, these indexes are built
while the encrypted column is new or freshly backfilled, where a plain
`CREATE INDEX` is correct. If a table is genuinely too hot for that, the
concurrent build has to happen outside the migration runner.

Everything above works as a non-superuser role (Supabase included); only the
ORE-flavour (`_ord_ore`) ordering opclass is superuser-gated. For the full
model — which domains take which index, engagement rules, `EXPLAIN`
verification, rollout timing — see the `stash-indexing` skill. For encrypted
predicates written as raw SQL rather than through the `cipherstash:*`
operators — operand casts to `eql_v3.query_*`, per-driver parameter binding —
see the `stash-postgres` skill.

**An EQL upgrade drops every index above, and `prisma-next migrate` will not
put them back.** Installing a new bundle begins with `DROP SCHEMA IF EXISTS
eql_v3 CASCADE`, which cascade-drops every functional index over an `eql_v3.*`
extractor — the PSL expression indexes and any `rawSql` index DDL alike.
Encrypted columns and their data survive and queries keep working — they just
silently sequential-scan again, so nothing errors and nothing warns.

Recovery is a **new** migration, because an applied one is never replayed: a
PSL expression index has to change its `name:` (the physical name carries a
content hash of the expression, so re-declaring the same index under the same
logical name plans no work), and a `rawSql` recovery operation needs a new op
`id` re-issuing the `CREATE INDEX` statements with its own `ANALYZE`. See
`stash-indexing` § "When to Create Indexes During an Encryption Rollout" for
the mechanism and the `EXPLAIN` check that confirms the indexes are back.
Capturing and restoring them automatically is tracked in
[cipherstash/stack#918](https://github.com/cipherstash/stack/issues/918).

## Writing and reading encrypted values

At the value boundary you wrap plaintext in a **runtime envelope** (primitive-named,
distinct from the domain-named column type) and unwrap with `decryptAll` +
`.decrypt()`:

```typescript
import {
  decryptAll,
  EncryptedString, EncryptedNumber, EncryptedBigInt,
  EncryptedDate, EncryptedBoolean, EncryptedJson,
} from '@cipherstash/stack-prisma/runtime'

await db.orm.public.User.create({
  id: 'user-0',
  email: EncryptedString.from('alice@example.com'),
  salary: EncryptedNumber.from(100_000),      // DoubleOrd column
  accountId: EncryptedBigInt.from(100_000_000_001n),
  birthday: EncryptedDate.from(new Date('1990-01-01')),
  emailVerified: EncryptedBoolean.from(true),
  preferences: EncryptedJson.from({ theme: 'dark' }),
})

const rows = await db.orm.public.User.where((u) => u.email.eqlEq('alice@example.com')).all()
await decryptAll(rows)                          // batches one SDK round-trip per (table,column)
console.log(await rows[0]?.email.decrypt())     // 'alice@example.com'
```

Envelopes pair by **plaintext type**, not by column name — one envelope
covers every domain of its family: `EncryptedString` ↔ all `Text*` columns,
`EncryptedNumber` ↔ all `number` families (`Integer*`, `Smallint*`,
`Numeric*`, `Real*`, `Double*`), `EncryptedBigInt` ↔ `BigInt*`,
`EncryptedDate` ↔ `Date*` and `Timestamp*`, `EncryptedBoolean` ↔ `Boolean`,
`EncryptedJson` ↔ `Json`.

## Query operators (`eql*`)

Operators live on the encrypted column inside `.where((u) => …)` and encrypt the
search term for you — Prisma Next never sees plaintext in a query. EQL v3 uses the
EQL-derived `eql*` vocabulary:

| Operator | Meaning | Requires |
|---|---|---|
| `eqlEq(v)` / `eqlNeq(v)` | equality / inequality | any searchable domain |
| `eqlIn(vs)` / `eqlNotIn(vs)` | membership | any searchable domain |
| `eqlMatch(term)` | free-text token match (`eql_v3.matches`) | `TextSearch` |
| `eqlGt/eqlGte/eqlLt/eqlLte(v)` | range comparison | an `*Ord` domain |
| `eqlBetween(lo,hi)` / `eqlNotBetween(lo,hi)` | range window | an `*Ord` domain |
| `eqlAsc(col)` / `eqlDesc(col)` | ORDER BY (free functions, take the column) | an `*Ord` or `TextSearch` domain |
| `eqlJsonContains(obj)` | encrypted JSON containment (`@>`) | `EncryptedJson` |
| `eqlJsonPathEq/Neq(path,v)` | exact value equality/inequality at a JSONPath | `EncryptedJson` |
| `eqlJsonPathGt/Gte/Lt/Lte(path,v)` | string/number ordering at a JSONPath | `EncryptedJson` |
| `eqlJsonPathAsc(col,path)` / `eqlJsonPathDesc(col,path)` | ORDER BY a scalar JSONPath leaf (free functions) | `EncryptedJson` |

```typescript
// range
await db.orm.public.User.where((u) => u.salary.eqlGt(100_000)).all()
// free-text
await db.orm.public.User.where((u) => u.email.eqlMatch('example.com')).all()
// between
await db.orm.public.User.where((u) => u.birthday.eqlBetween(lo, hi)).all()
// bigint membership
await db.orm.public.User.where((u) => u.accountId.eqlIn([100_000_000_001n])).all()
// encrypted JSON containment
await db.orm.public.User.where((u) => u.preferences.eqlJsonContains({ theme: 'dark' })).all()
// exact JSONPath equality (value-selector containment; GIN-indexable)
await db.orm.public.User.where((u) => u.preferences.eqlJsonPathEq('$.theme', 'dark')).all()
// JSONPath ordering (ciphertext-free selector + scalar term)
await db.orm.public.User.where((u) => u.preferences.eqlJsonPathGte('$.score', 80)).all()
// ordering
import { eqlAsc } from '@cipherstash/stack-prisma/runtime'
await db.orm.public.User.orderBy((u) => eqlAsc(u.salary)).all()

// ordering by a JSONPath leaf; missing paths follow PostgreSQL NULL ordering
import { eqlJsonPathAsc } from '@cipherstash/stack-prisma/runtime'
await db.orm.public.User.orderBy((u) => eqlJsonPathAsc(u.preferences, '$.score')).all()
```

Applying an operator its domain doesn't support (e.g. `eqlGt` on a
storage-only `EncryptedBoolean`, or `eqlMatch` on a non-text domain) is a typed
error at build time, not a runtime surprise.

## Authentication

Same credential model as the rest of Stack:

- **Local dev:** `npx stash auth login` (device-code flow; token in `~/.cipherstash`).
- **CI / production:** the four `CS_*` env vars (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`,
  `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`), minted with `stash env`. The
  `stash-auth` skill is canonical for credentials and auth strategies;
  `stash-zerokms` for keysets and what the credentials can reach.

`cipherstashFromStack` resolves `CS_*` when present, else the local profile.

## Bundling

`@cipherstash/stack` wraps a native FFI module and must be excluded from bundling
(`serverExternalPackages`, esbuild `external`, etc.) — see the `stash-encryption`
skill's bundling section. The Prisma Next adapter is **native-only**:
`cipherstashFromStack` constructs the native `@cipherstash/stack` client, and
there is no `wasm-inline` variant of this adapter — the WASM entry is a
different client for non-Prisma edge paths (`stash-edge`), not a drop-in here.
Run Prisma Next apps on a Node runtime where the native module loads.

## Subpath exports

| Subpath | Purpose |
|---|---|
| `@cipherstash/stack-prisma/v3` | The v3 surface: `cipherstashFromStack`, the SDK adapter, envelopes/middleware |
| `@cipherstash/stack-prisma/control` | The extension pack for `extensions: [...]` |
| `@cipherstash/stack-prisma/runtime` | Envelope classes, `decryptAll`, `eql*` operators, `EncryptedString.from()`… |
| `@cipherstash/stack-prisma/stack` | One-call setup against `@cipherstash/stack`: `cipherstashFromStack` |
| `@cipherstash/stack-prisma/column-types` | camelCase factories (`textSearch`, `bigIntOrd`, …) for **TS-authored** contracts — emits byte-identical `contract.json` to the PSL constructors |

## Gotchas

- **EQL installs via `prisma-next migrate` (top-level, not `migration apply`), never `stash eql install`.**
- **Column type (schema, domain-named) ≠ runtime envelope (value, primitive-named).**
  `DoubleOrd` column ↔ `EncryptedNumber.from(...)` value.
- **Regenerate the contract** (`prisma-next contract emit`) after changing a
  column's encrypted type, so `cipherstashFromStack` and the migrations agree.
- **Re-plan after upgrading `@cipherstash/stack-prisma`** — `rm -rf
  migrations/cipherstash && npx prisma-next migration plan`. Only `migration
  plan` vendors new migration packages; skip it and a fresh `db init` fails with
  `... has class "data" which is not allowed by policy.`
- **Never log or read `~/.cipherstash`** or `.env*` credential files (see `stash-cli`).
