Skip to content

Commit 0189c9c

Browse files
committed
docs(specs): EQL v3 Prisma ORM integration design — 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).
1 parent 7f67e11 commit 0189c9c

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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

Comments
 (0)