|
| 1 | +# EQL v3 Prisma ORM support — design |
| 2 | + |
| 3 | +Status: proposed (spike complete, findings below) |
| 4 | +Date: 2026-07-06 |
| 5 | +Ticket: CIP-3302 |
| 6 | +Branch: `james/cip-3302-integrate-eql-v3-into-prisma-orm` |
| 7 | + |
| 8 | +## 1. Goal |
| 9 | + |
| 10 | +Add EQL v3 support for **vanilla Prisma ORM** (`@prisma/client`) to |
| 11 | +`@cipherstash/stack`, at the same layer as the Supabase integration |
| 12 | +(`src/supabase`) and the Drizzle v3 integration (`src/eql/v3/drizzle`, PR #565). |
| 13 | + |
| 14 | +Success = a developer can declare encrypted columns for a Prisma model, feed |
| 15 | +the same `@cipherstash/stack/eql/v3` schema to `EncryptionV3`, and run |
| 16 | +equality / range / free-text queries through their Prisma client with |
| 17 | +transparent encrypt-on-write / decrypt-on-read. |
| 18 | + |
| 19 | +Not to be confused with `@cipherstash/prisma-next` (PR #515) — the in-house |
| 20 | +ORM is a separate track; nothing here touches it. |
| 21 | + |
| 22 | +## 2. Spike findings (2026-07-06) |
| 23 | + |
| 24 | +Environment: Prisma **7.8.0** + `@prisma/adapter-pg` (driver adapters are |
| 25 | +mandatory in v7; `datasource.url` in `schema.prisma` is a validation error), |
| 26 | +PG 17 with the vendored `eql_v3` bundle |
| 27 | +(`packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql`). |
| 28 | +Table: `spike_users (email eql_v3.text_eq, age eql_v3.integer_ord)`, Prisma |
| 29 | +model declares both as `Json?`. Envelopes hand-crafted (domain CHECKs verify |
| 30 | +key shape only; the `=` operator compares `hm` terms, so a same-`hm` / |
| 31 | +different-`c` operand distinguishes domain-operator resolution from jsonb |
| 32 | +structural equality). |
| 33 | + |
| 34 | +| # | Path | Result | |
| 35 | +|---|------|--------| |
| 36 | +| 1 | `create` with `Json` field → domain column | ✅ works (plain `INSERT … VALUES ($1,$2)`, assignment cast jsonb→domain) | |
| 37 | +| 2 | `createMany` (valid envelopes) | ✅ works | |
| 38 | +| 3 | `create`/`createMany` with plain `null` on a `Json` field | ❌ Prisma writes **JSON `null`**, which fails the domain CHECK (`jsonb_typeof ≠ 'object'`). `Prisma.DbNull` writes SQL NULL and works. The extension must normalize `null → DbNull` for encrypted columns. | |
| 39 | +| 4 | CHECK-violating payload | ✅ rejected by PG (defense in depth holds) | |
| 40 | +| 5 | `findMany` read-back | ✅ envelope round-trips as a JS object | |
| 41 | +| 6 | Typed `where` `equals` with full envelope | ❌ **domain operator bypassed.** Prisma emits `"col"::jsonb = $1` — the column-side cast smashes the domain to base jsonb, so comparison is structural. Same-`hm`/different-`c` does not match. (A byte-identical envelope *does* match structurally, but with real encryption the operand's ciphertext always differs — so in production this silently returns zero rows.) | |
| 42 | +| 7 | Typed `where` `gt/gte/lt/lte` on `Json` | ❌ rejected by Prisma client validation — not part of the Json filter surface | |
| 43 | +| 8 | `$queryRaw` with native domain operator: `email = $1::jsonb` | ✅ matches via `eql_v3.eq` (the PostgREST/Supabase lowering) | |
| 44 | +| 9 | `$queryRaw` with extracted terms: `eql_v3.eq_term(email) = eql_v3.eq_term($1::jsonb::eql_v3.text_eq)` | ✅ matches (the Drizzle v3 lowering; note the operand needs the double cast, or use the two-arg `eql_v3.eq(col, $1::jsonb)` function form) | |
| 45 | +| 10 | `updateMany` with `Json` field | ✅ works | |
| 46 | + |
| 47 | +**Conclusions.** |
| 48 | + |
| 49 | +1. **Column strategy: `Json` fields over domain-typed DB columns.** Reads and |
| 50 | + writes work transparently through the standard client; PG's assignment |
| 51 | + cast + domain CHECK validate on the way in. `Unsupported("eql_v3.…")` |
| 52 | + is rejected as primary strategy — it removes the field from the generated |
| 53 | + client entirely. |
| 54 | +2. **Typed `where` is a dead end for encrypted search** — equality is |
| 55 | + silently wrong (6) and range doesn't exist (7). All encrypted filtering |
| 56 | + must lower to raw SQL. This also means the integration must **guard** |
| 57 | + against users passing encrypted columns to plain `where` (silent zero-row |
| 58 | + results are the worst failure mode). |
| 59 | +3. Both raw lowering styles work; prefer the **extracted-term dialect** |
| 60 | + (shared with Drizzle v3) so the two integrations emit identical SQL and |
| 61 | + the CIP-3402 term-envelope swap lands in one place. |
| 62 | +4. Prisma 7's driver-adapter requirement shapes the docs/example app but not |
| 63 | + the integration design ($extends and $queryRaw are unchanged). |
| 64 | + |
| 65 | +## 3. Architecture |
| 66 | + |
| 67 | +``` |
| 68 | +packages/stack/src/eql/v3/prisma/ |
| 69 | + index.ts // barrel: encryptedPrisma, whereEncrypted helpers, errors |
| 70 | + extension.ts // $extends factory: encrypt-on-write / decrypt-on-read |
| 71 | + model-map.ts // Prisma model name ↔ v3 encryptedTable registration |
| 72 | + where.ts // capability-checked Prisma.sql fragment builders |
| 73 | + sql-dialect.ts // extracted-term SQL emission (share/extract from drizzle v3 when #565 lands) |
| 74 | + null-handling.ts // null → Prisma.DbNull normalization for encrypted fields |
| 75 | +``` |
| 76 | + |
| 77 | +Exported at `@cipherstash/stack/eql/v3/prisma` (package.json `exports` + tsup |
| 78 | +entry). Depends inward on `@/eql/v3` (concrete types = single source of truth |
| 79 | +for domain / `cast_as` / capabilities) and `@/encryption` only. No dependency |
| 80 | +on the v2 modules or on `@prisma/client` itself (structural typing over the |
| 81 | +client, mirroring `SupabaseClientLike`). |
| 82 | + |
| 83 | +### 3.1 Write/read transparency: `$extends` query component |
| 84 | + |
| 85 | +`encryptedPrisma({ encryptionClient, prismaClient, tables })` returns |
| 86 | +`prismaClient.$extends({ query: { $allModels: { … } } })` where `tables` maps |
| 87 | +Prisma model names to v3 `encryptedTable` schemas. |
| 88 | + |
| 89 | +- **Writes** (`create`, `update`, `upsert`, `createMany`, `createManyAndReturn`, |
| 90 | + `updateMany`): plaintext values on registered encrypted fields are encrypted |
| 91 | + via `encryptModel` / `bulkEncryptModels`; `null` becomes `Prisma.DbNull` |
| 92 | + (finding 3). |
| 93 | +- **Reads** (`findMany`, `findFirst`, `findUnique`, and the `*OrThrow` |
| 94 | + variants, plus the rows returned by mutating calls): envelopes on registered |
| 95 | + fields are decrypted via `bulkDecryptModels`, with `Date` reconstruction |
| 96 | + from `cast_as` and native `bigint` passthrough (parity with |
| 97 | + `src/encryption/v3.ts`). |
| 98 | +- **Guard**: any registered encrypted field appearing inside `where` / |
| 99 | + `orderBy` / `distinct` of an intercepted call throws |
| 100 | + `PrismaEncryptedColumnError` — never silently returns wrong results |
| 101 | + (finding 6). Plaintext (unregistered) fields pass through untouched. |
| 102 | +- Lock context + audit config passthrough, same surface as the Supabase |
| 103 | + builder (`withLockContext`, `audit`). |
| 104 | + |
| 105 | +### 3.2 Encrypted filtering: `Prisma.sql` fragment builders |
| 106 | + |
| 107 | +Typed `where` cannot express encrypted search, so filtering is explicit: |
| 108 | + |
| 109 | +```ts |
| 110 | +const { whereEq, whereGt, whereMatch } = encryptedWhere(client, users) |
| 111 | + |
| 112 | +const rows = await eprisma.$queryRawEncrypted( |
| 113 | + users, |
| 114 | + Prisma.sql`SELECT * FROM users WHERE ${await whereEq(users.email, 'a@b.com')} AND plan = ${plan}`, |
| 115 | +) |
| 116 | +``` |
| 117 | + |
| 118 | +- Each builder is **capability-checked** against |
| 119 | + `column.getQueryCapabilities()` (storage-only columns and |
| 120 | + operator/capability mismatches throw — runtime guard now, type-level |
| 121 | + narrowing like `V3FilterableKeys` where feasible). |
| 122 | +- Operand encryption reuses the **interim full-envelope encoding** with the |
| 123 | + same single-swap-point discipline as the Supabase builder |
| 124 | + (`query-builder-v3.ts#encryptCollectedTerms`, CIP-3402) — or `encryptQuery` |
| 125 | + terms if #565's encrypt-query path lands first; align with whichever ships. |
| 126 | +- SQL emission is the extracted-term dialect (finding 9), using the two-arg |
| 127 | + function forms (`eql_v3.eq(col, $::jsonb)`) to avoid the double-cast |
| 128 | + wrinkle. |
| 129 | +- Free-text: bloom containment (`match_term @> bloom_filter`), documented as |
| 130 | + token match, not SQL `LIKE` (same caveat as Drizzle/Supabase). |
| 131 | +- `$queryRawEncrypted(table, sql)` wraps `$queryRaw` and decrypts the result |
| 132 | + rows against the table schema. |
| 133 | + |
| 134 | +### 3.3 DDL / migration story |
| 135 | + |
| 136 | +`prisma migrate` emits `jsonb` for `Json` fields; the DB column must be the |
| 137 | +domain. Ship documented, copy-pasteable migration SQL |
| 138 | +(`ALTER TABLE … ALTER COLUMN … TYPE eql_v3.text_eq USING …` or authoring the |
| 139 | +column as the domain in the initial migration — Prisma migrations are plain |
| 140 | +SQL files, so hand-edits are first-class) plus the `eql_v3` bundle install |
| 141 | +(reuse `scripts/install-eql-v3.ts` / CLI installer). A codegen step that |
| 142 | +patches migrations automatically is a follow-up, not v1. |
| 143 | + |
| 144 | +## 4. Scope |
| 145 | + |
| 146 | +### In scope |
| 147 | +- The module above, for every shipped scalar domain (text/int/float/numeric/ |
| 148 | + date/timestamp/bool families; bigint rides in with #557's release gate). |
| 149 | +- Unit tests with a mocked Prisma client (mirror the mock-Supabase pattern), |
| 150 | + `.test-d.ts` type tests, capability-mismatch matrix, live-gated PG tests |
| 151 | + (`DATABASE_URL` + `CS_*`, reuse `__tests__/helpers/live-gate.ts`). |
| 152 | +- Example app under `examples/` (note `examples/prisma` belongs to |
| 153 | + prisma-next; new directory, e.g. `examples/prisma-orm`), Prisma 7 + |
| 154 | + driver-adapter shaped. |
| 155 | +- README + `docs/query-api-walkthrough.md` sections, changeset (minor). |
| 156 | + |
| 157 | +### Out of scope |
| 158 | +- JSON / `ste_vec` columns (no v3 JSON builder exists yet). |
| 159 | +- Encrypted `ORDER BY` through the typed API (raw-SQL fragment at most). |
| 160 | +- Automatic migration patching (documented manual edit in v1). |
| 161 | +- Prisma < 7 compatibility testing (the `::jsonb` column cast predates v7, so |
| 162 | + the typed-where conclusion holds for v6; the extension targets the |
| 163 | + client-extension API, GA since v4.16 — verify on v6 opportunistically). |
| 164 | +- Any change to `@cipherstash/prisma-next` or the v2 integrations. |
| 165 | + |
| 166 | +## 5. Sequencing |
| 167 | + |
| 168 | +1. **#565 (Drizzle v3)** first if possible — shares the encrypt-query path |
| 169 | + and the term-function SQL dialect this module wants to extract/reuse. |
| 170 | +2. This module's core (extension + where builders) against the interim |
| 171 | + full-envelope operand. |
| 172 | +3. CIP-3402 lands the term-only envelope → swap inside the one operand- |
| 173 | + encryption method. |
0 commit comments