stash-prisma
GitHub指导在 Prisma Next 项目中集成 CipherStash EQL v3 加密。涵盖 schema.prisma 中加密列类型的声明、npm 安装配置、运行时 cipherstashFromStack 接线,以及 eql* 查询操作符的使用和迁移流程。
触发场景
安装
npx skills add cipherstash/stack --skill stash-prisma -g -y
SKILL.md
Frontmatter
{
"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-prismais 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
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:
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
*Orddomain includes equality (equality + range + ORDER BY); every*Eqdomain is equality only; the bare family name is storage-only (encrypt/decrypt, no operators).TextMatchis free-text only — no equality.TextSearchcarries all three text capabilities. - The plaintext type matters as much as the capability. Money stored as
integer cents wants
IntegerOrd()(JSnumber) — notDoubleOrd()(float semantics) and notBigIntOrd(), whose plaintext is a JSbigintand rejectsnumbervalues. - The
*OrdOrevariants exist in the database bundle but are deliberately not exposed as constructors (their btree opclass is superuser-gated — seestash-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:
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
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:
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:
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 edge.
Skipping it is not always fatal, which is what makes it easy to miss. 1.0.0
shipped the baseline at eql-3.0.4; later versions bake eql-3.0.5 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 the 3.0.5 upgrade edge only. |
prisma-next migrate (fresh database) |
Correct end state, but installs eql-3.0.4 and then immediately re-installs eql-3.0.5 over it. |
prisma-next db init (fresh database) |
Fails: Operation cipherstash.upgrade-eql-v3-bundle-3.0.5 has class "data" which is not allowed by policy. db init is additive-only and the stale baseline does not carry the 3.0.5 invariant, so the planner has to reach for the data-classed upgrade edge. 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'sclientoption 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 emittedcontract.json(src/prisma/,prisma/, or the project root), and derives the schemas the same way the runtime does. So runprisma-next contract emitbeforestash encrypt backfill, and don't hand-author a bridge client file. - The tracking schema is created for you.
cipherstash.cs_migrationsis normally created bystash eql install, which this integration never runs.stash encrypt backfillbootstraps 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:
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:
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.
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():
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 |
// 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 withstash env. Thestash-authskill is canonical for credentials and auth strategies;stash-zerokmsfor 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, notmigration apply), neverstash eql install. - Column type (schema, domain-named) ≠ runtime envelope (value, primitive-named).
DoubleOrdcolumn ↔EncryptedNumber.from(...)value. - Regenerate the contract (
prisma-next contract emit) after changing a column's encrypted type, socipherstashFromStackand the migrations agree. - Re-plan after upgrading
@cipherstash/stack-prisma—rm -rf migrations/cipherstash && npx prisma-next migration plan. Onlymigration planvendors new migration packages; skip it and a freshdb initfails with... has class "data" which is not allowed by policy. - Never log or read
~/.cipherstashor.env*credential files (seestash-cli).
版本历史
- 0854bda 当前 2026-08-28 21:34
- f2c5d32 2026-08-20 10:21


