@@ -56,21 +56,81 @@ structural equality).
5656 must lower to raw SQL. This also means the integration must ** guard**
5757 against users passing encrypted columns to plain ` where ` (silent zero-row
5858 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.
59+ 3 . Both raw lowering styles work on main's bundle. Use the ** two-arg
60+ function forms** (` eql_v3.eq(col, $::jsonb) ` etc.) with full-envelope
61+ operands; emitting Drizzle-v3-identical SQL is not achievable today
62+ because #565 targets a different bundle generation (see §2b).
62634 . Prisma 7's driver-adapter requirement shapes the docs/example app but not
6364 the integration design ($extends and $queryRaw are unchanged).
6465
66+ ## 2b. Insights from the Drizzle v3 implementation (PR #565 , reviewed 2026-07-06)
67+
68+ #565 landed a full implementation of the sibling integration. What transfers,
69+ what doesn't, and what it changes here:
70+
71+ ** Bundle-generation split — the biggest caution.** #565 is built on the
72+ ** protect-ffi 0.26** line: pre-rename domain names (` eql_v3.int4_ord ` ), and a
73+ bundle that exposes ** public** term constructors
74+ (` eql_v3.hmac_256/ore_block_256/bloom_filter ` ) accepting term-only jsonb; on
75+ that line ` client.encryptQuery ` produces v3 scalar terms. Main — this
76+ module's base — is the ** protect-ffi 0.27** line: SQL-standard domain names
77+ (` eql_v3.integer_ord ` ), constructors in ** ` eql_v3_internal ` ** only, public
78+ two-arg wrappers (` eql_v3.eq(col, $::jsonb) ` ) that ** coerce the operand into
79+ the domain** (so it must be a full envelope passing the CHECK), and scalar
80+ ` encryptQuery ` throwing ` EQL_V3_QUERY_UNSUPPORTED ` . Consequences:
81+
82+ - #565 's exact SQL (` eql_v3.hmac_256(…) ` ) and its term-operand path ** do not
83+ work against main's bundle** . This module keeps the full-envelope operand
84+ (spike-verified via both the native domain operator and the
85+ ` eq_term(col) = eq_term($::jsonb::domain) ` form, which the two-arg
86+ ` eql_v3.eq(col, $::jsonb) ` wrapper is equivalent to).
87+ - Code-sharing #565 's ` sql-dialect.ts ` is off the table for now — it is
88+ drizzle-` SQL ` -typed and pinned to the other bundle generation. Share the
89+ ** shape** (the dialect table below), not the module; extracting a
90+ driver-neutral dialect is a follow-up once both integrations sit on one
91+ bundle generation.
92+ - Sequencing flips: #565 must reconcile with main's 0.27/SQL-standard-name
93+ line before anything can be shared. This module should ** not block on it** .
94+
95+ ** What does transfer (adopt directly):**
96+
97+ 1 . ** The dialect shape.** A small object keyed off ` builder.build().indexes `
98+ (` unique ` /` ore ` /` match ` — the authoritative index set, which #565 uses for
99+ gating instead of ` getQueryCapabilities() ` ; align on that here too), with:
100+ equality (` unique ` → hmac path), ** equality-via-ORE fallback** for
101+ ord-only columns (` ord_term = ore-constructor ` , `queryType:
102+ 'orderAndRange'` ), comparison, ` between` / ` notBetween`, match, and
103+ ` orderBy ` .
104+ 2 . ** Encrypted ` ORDER BY ` is in scope** for raw-SQL integrations: `ORDER BY
105+ eql_v3.ord_term(col)` (requires ` ore` ). #565 ships ` asc` / ` desc`; this
106+ module ships an ` orderByEncrypted ` fragment builder. (Supersedes the
107+ earlier out-of-scope entry.)
108+ 3 . ** ` inArray ` /` notInArray ` ** as OR-of-equality / AND-of-inequality terms,
109+ encrypting operands with ** bounded concurrency** (#565 caps at 4) — adopt
110+ for ` whereIn ` /` whereNotIn ` builders.
111+ 4 . ** No-silent-fallback errors.** v3 operators throw a named error
112+ (` EncryptionOperatorError ` ) carrying ` { columnName, tableName, operator } `
113+ when a column lacks the required index or isn't a v3 column at all — no
114+ fall-through to plain operators (v2 behaviour), because wrong-operator SQL
115+ fails the domain CHECK at runtime anyway. Matches and strengthens this
116+ design's typed-` where ` guard; adopt the same error-context convention.
117+ 5 . ** Null codec confirmation.** #565 's ` toDriver ` maps ` null ` /` undefined ` →
118+ SQL NULL for exactly the domain-CHECK reason spike finding 3 hit. Here
119+ only ` null → Prisma.DbNull ` transfers: Prisma's ` undefined ` means "field
120+ not provided" and must be left untouched (coercing it would null out
121+ columns on update — a Drizzle-codec detail that does not carry over).
122+ 6 . ** Per-table schema cache** (WeakMap keyed by the ORM table object) for
123+ resolving a column's owning ` encryptedTable ` — mirror in ` model-map.ts ` .
124+
65125## 3. Architecture
66126
67127```
68128packages/stack/src/eql/v3/prisma/
69129 index.ts // barrel: encryptedPrisma, whereEncrypted helpers, errors
70130 extension.ts // $extends factory: encrypt-on-write / decrypt-on-read
71131 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 )
132+ where.ts // capability-checked Prisma.sql fragment builders (incl. whereIn, orderByEncrypted)
133+ sql-dialect.ts // Prisma.sql emission; mirrors #565's dialect SHAPE (code-sharing blocked by the bundle split, §2b )
74134 null-handling.ts // null → Prisma.DbNull normalization for encrypted fields
75135```
76136
@@ -89,7 +149,9 @@ Prisma model names to v3 `encryptedTable` schemas.
89149- ** Writes** (` create ` , ` update ` , ` upsert ` , ` createMany ` , ` createManyAndReturn ` ,
90150 ` updateMany ` ): plaintext values on registered encrypted fields are encrypted
91151 via ` encryptModel ` / ` bulkEncryptModels ` ; ` null ` becomes ` Prisma.DbNull `
92- (finding 3).
152+ (finding 3; #565 's codec confirms the domain-CHECK rationale, §2b).
153+ ` undefined ` is left untouched — in Prisma it means "field not provided",
154+ and coercing it to ` DbNull ` would null out columns on update.
93155- ** Reads** (` findMany ` , ` findFirst ` , ` findUnique ` , and the ` *OrThrow `
94156 variants, plus the rows returned by mutating calls): envelopes on registered
95157 fields are decrypted via ` bulkDecryptModels ` , with ` Date ` reconstruction
@@ -115,19 +177,27 @@ const rows = await eprisma.$queryRawEncrypted(
115177)
116178```
117179
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).
180+ - Each builder is ** capability-checked** against ` builder.build().indexes `
181+ (` unique ` /` ore ` /` match ` — the authoritative index set, per #565 ; see §2b).
182+ Storage-only columns and operator/capability mismatches throw a named error
183+ with ` { columnName, tableName, operator } ` context — runtime guard now,
184+ type-level narrowing like ` V3FilterableKeys ` where feasible. Equality on an
185+ ord-only column falls back to ORE (` queryType: 'orderAndRange' ` ), mirroring
186+ #565 .
122187- Operand encryption reuses the ** interim full-envelope encoding** with the
123188 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).
189+ (` query-builder-v3.ts#encryptCollectedTerms ` , CIP-3402). #565 's
190+ ` encryptQuery ` term path does NOT transfer to main's protect-ffi 0.27 line
191+ (§2b) — revisit only when the term-only envelope ships, and note the
192+ lowering must switch away from the domain-coercing forms at the same time
193+ (a term-only operand fails the domain CHECK).
194+ - SQL emission uses the two-arg function forms (` eql_v3.eq(col, $::jsonb) ` ,
195+ finding 9 equivalent) to avoid the double-cast wrinkle. ` whereIn ` = OR of
196+ equality fragments with bounded operand-encryption concurrency;
197+ ` orderByEncrypted ` = ` eql_v3.ord_term(col) ` (requires ` ore ` ).
198+ - Free-text: bloom containment via the two-arg `eql_v3.contains(col,
199+ $::jsonb)` (equivalently the native ` @>` operator Supabase's ` cs` uses),
200+ documented as token match, not SQL ` LIKE ` (same caveat as Drizzle/Supabase).
131201- ` $queryRawEncrypted(table, sql) ` wraps ` $queryRaw ` and decrypts the result
132202 rows against the table schema.
133203
@@ -156,7 +226,8 @@ patches migrations automatically is a follow-up, not v1.
156226
157227### Out of scope
158228- JSON / ` ste_vec ` columns (no v3 JSON builder exists yet).
159- - Encrypted ` ORDER BY ` through the typed API (raw-SQL fragment at most).
229+ - Encrypted ` ORDER BY ` through the ** typed** API (` orderBy: {…} ` ) — but the
230+ raw-SQL ` orderByEncrypted ` fragment builder IS in scope (§2b insight 2).
160231- Automatic migration patching (documented manual edit in v1).
161232- Prisma < 7 compatibility testing (the ` ::jsonb ` column cast predates v7, so
162233 the typed-where conclusion holds for v6; the extension targets the
@@ -165,9 +236,14 @@ patches migrations automatically is a follow-up, not v1.
165236
166237## 5. Sequencing
167238
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.
239+ 1 . ** Do not block on #565 .** It is built on the protect-ffi 0.26 /
240+ pre-rename-domain line and must reconcile with main's 0.27 /
241+ SQL-standard-name line before its dialect or encrypt-query path can be
242+ shared (§2b). This module proceeds against main directly.
1702432 . This module's core (extension + where builders) against the interim
171- full-envelope operand.
244+ full-envelope operand, mirroring # 565 's dialect * shape * only .
1722453 . CIP-3402 lands the term-only envelope → swap inside the one operand-
173- encryption method.
246+ encryption method AND switch the lowering off the domain-coercing forms
247+ in the same change (term-only operands fail the domain CHECK).
248+ 4 . Follow-up once both integrations share a bundle generation: extract a
249+ driver-neutral term-SQL dialect.
0 commit comments