Agent Skillscipherstash/stack › stash-prisma

stash-prisma

GitHub

指导在 Prisma Next 项目中集成 CipherStash EQL v3 加密。涵盖 schema.prisma 中加密列类型的声明、npm 安装配置、运行时 cipherstashFromStack 接线,以及 eql* 查询操作符的使用和迁移流程。

skills/stash-prisma/SKILL.md cipherstash/stack

Trigger Scenarios

在 Prisma Next 项目中添加字段级加密 在 schema.prisma 中声明加密列类型 使用 eql* 操作符查询加密数据 升级 @cipherstash/stack-prisma 库

Install

npx skills add cipherstash/stack --skill stash-prisma -g -y
More Options

Use without installing

npx skills use cipherstash/stack@stash-prisma

指定 Agent (Claude Code)

npx skills add cipherstash/stack --skill stash-prisma -a claude-code -g -y

安装 repo 全部 skill

npx skills add cipherstash/stack --all -g -y

预览 repo 内 skill

npx skills add cipherstash/stack --list

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-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

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 *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:

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'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:

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*), EncryptedBigIntBigInt*, EncryptedDateDate* and Timestamp*, EncryptedBooleanBoolean, EncryptedJsonJson.

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 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-prismarm -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).

Version History

  • 0854bda Current 2026-08-28 21:34
  • f2c5d32 2026-08-20 10:21

Same Skill Collection

.claude/skills/meta-issue-creation/SKILL.md
.claude/skills/meta-pr-creation/SKILL.md
skills/stash-cli/SKILL.md
skills/stash-dynamodb/SKILL.md
skills/stash-encryption/SKILL.md
skills/stash-auth/SKILL.md
skills/stash-deployment/SKILL.md
skills/stash-drizzle/SKILL.md
skills/stash-edge/SKILL.md
skills/stash-indexing/SKILL.md
skills/stash-managed-platforms/SKILL.md
skills/stash-postgres/SKILL.md
skills/stash-supabase/SKILL.md
skills/stash-supply-chain-security/SKILL.md
skills/stash-zerokms/SKILL.md

Metadata

Files
0
Version
0854bda
Hash
c4d4664a
Indexed
2026-08-20 10:21

inicio - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-09-17 05:05
浙ICP备14020137号-1