Skip to content

feat(stack): EQL v3 Prisma ORM integration — CIP-3302#566

Draft
freshtonic wants to merge 3 commits into
mainfrom
james/cip-3302-integrate-eql-v3-into-prisma-orm
Draft

feat(stack): EQL v3 Prisma ORM integration — CIP-3302#566
freshtonic wants to merge 3 commits into
mainfrom
james/cip-3302-integrate-eql-v3-into-prisma-orm

Conversation

@freshtonic

Copy link
Copy Markdown
Contributor

Summary

Adds EQL v3 support for vanilla Prisma ORM (@prisma/client) to @cipherstash/stack, at the same layer as the Supabase integration (src/supabase) and the Drizzle v3 integration (#565). Not to be confused with @cipherstash/prisma-next (#515) — that's the in-house ORM, a separate track.

Current state: design doc only (docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md), validated by a live spike. Implementation lands in follow-up commits on this branch.

Spike findings (Prisma 7.8.0 + @prisma/adapter-pg, PG 17 + eql_v3 bundle)

  • Json fields over domain-typed DB columns work for the full CRUD surface — create/createMany/update/updateMany/findMany round-trip envelopes; PG assignment-casts jsonb→domain and the CHECK validates on write. Unsupported("eql_v3.…") rejected (removes the field from the generated client).
  • Typed where is a dead end: Prisma lowers Json equals to "col"::jsonb = $1 — the column-side cast bypasses the domain's custom =, so comparison is structural (silent zero rows with real ciphertexts). Range ops on Json don't exist. The extension must throw on encrypted columns in typed where.
  • Both raw lowerings verified via $queryRaw: native domain operator (email = $1::jsonb) and extracted terms (eql_v3.eq_term(email) = eql_v3.eq_term($1::jsonb::eql_v3.text_eq)). Design picks the extracted-term dialect shared with Add EQL v3 Drizzle support #565.
  • Null footgun: plain null on a Json field writes JSON null → fails the domain CHECK; must normalize to Prisma.DbNull.

Planned architecture (per design doc)

packages/stack/src/eql/v3/prisma/ exported at @cipherstash/stack/eql/v3/prisma:

  • $extends query component: transparent encrypt-on-write / decrypt-on-read (encryptModel/bulkDecryptModels, Date/bigint reconstruction, null→DbNull, lock context + audit passthrough)
  • Capability-checked Prisma.sql where-fragment builders + $queryRawEncrypted, interim full-envelope operands with the CIP-3402 single-swap-point discipline
  • Documented manual migration edit for domain DDL in v1

Sequencing

Test plan

  • Unit tests (mocked Prisma client), .test-d.ts type tests, capability-mismatch matrix
  • Live-gated PG tests (DATABASE_URL + CS_*)
  • Example app under examples/ (Prisma 7, driver-adapter shaped)

Refs CIP-3302

Spike-validated design for vanilla Prisma (@prisma/client) support at the
same layer as the Supabase and Drizzle v3 integrations. Key findings:
Json fields over domain-typed columns round-trip CRUD; Prisma's typed
where casts the column side to jsonb and bypasses the domain operators,
so all encrypted filtering lowers to raw SQL; plain null on Json fields
writes JSON null and fails the domain CHECK (must normalize to DbNull).
@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e1833a4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a8a2c78-7e54-4301-8887-bc1af23652ae

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch james/cip-3302-integrate-eql-v3-into-prisma-orm

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

#565 ships the sibling integration but on the protect-ffi 0.26 /
pre-rename-domain bundle line, while main (this module's base) is 0.27 +
SQL-standard names with term constructors in eql_v3_internal and scalar
encryptQuery unsupported — so its exact SQL and term-operand path do not
transfer. Adopt its dialect shape instead: gating on build().indexes,
equality-via-ORE fallback, encrypted ORDER BY (now in scope as a fragment
builder), whereIn with bounded operand-encryption concurrency, and the
no-silent-fallback error convention. Free-text match pinned to the two-arg
eql_v3.contains form; sequencing no longer blocks on #565.
@freshtonic

Copy link
Copy Markdown
Contributor Author

Design doc updated with insights from the Drizzle v3 implementation (#565) — new §2b.

Headline: #565 does not transfer wholesale. It targets the protect-ffi 0.26 / pre-rename-domain bundle line (eql_v3.int4_ord, public eql_v3.hmac_256 term constructors, working v3 scalar encryptQuery), while main — this branch's base — is 0.27 + SQL-standard names (eql_v3.integer_ord), term constructors in eql_v3_internal only, public two-arg wrappers that coerce operands into the domain (full envelope required), and encryptQuery throwing EQL_V3_QUERY_UNSUPPORTED for v3 scalars. So this module keeps the full-envelope operand + two-arg function lowering (eql_v3.eq(col, $::jsonb), eql_v3.contains(col, $::jsonb)), shares #565's dialect shape rather than its code, and no longer sequences behind it.

What was adopted from #565: capability gating on build().indexes, equality-via-ORE fallback for ord-only columns, encrypted ORDER BY fragment builder (eql_v3.ord_term(col)) now in scope, whereIn as OR-of-equality with bounded operand-encryption concurrency, and the no-silent-fallback error convention ({ columnName, tableName, operator } context).

One deliberate divergence: #565's codec maps undefined → SQL NULL, but in Prisma undefined means "field not provided" — only null → Prisma.DbNull transfers, or updates would null out untouched columns.

Implements the design doc: @cipherstash/stack/eql/v3/prisma exporting
encryptedPrisma({ encryptionClient, prismaClient, prisma, tables }) →
{ client, where, $queryRawEncrypted }.

- $extends query hook: encrypt-on-write (create/update/upsert/createMany/
  updateMany + AndReturn variants) via encryptModel/bulkEncryptModels,
  null → Prisma.DbNull (domain CHECK rejects JSON null), undefined
  preserved as "not provided"; decrypt-on-read with Date reconstruction.
- Typed-query guard: encrypted columns in where/orderBy/distinct/cursor/
  having throw PrismaEncryptedColumnError (Prisma's Json lowering casts
  the column side to jsonb, bypassing the eql_v3 operators — typed
  filters would silently match nothing).
- Capability-checked Prisma.sql fragment builders gated on
  build().indexes: eq/ne/gt/gte/lt/lte/between/notBetween/contains/in/
  notIn/orderBy/isNull/isNotNull, lowering to the two-arg eql_v3.*
  function forms with full-envelope operands (interim — single swap
  point for CIP-3402/CIP-3423), equality-via-ORE on ord-only columns
  handled by the bundle's per-domain functions.
- $queryRawEncrypted decrypts raw rows (db-name keyed) against any v3
  table; lock context + audit passthrough at config and call level.
- Prisma namespace (sql, DbNull) is caller-injected: Prisma 7 generates
  the client to a user-chosen path, so the library cannot resolve it.

72 unit tests + type tests + CJS bundle coverage; package export,
tsup entry, changeset (minor).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant