Skip to content

Commit 2355bdb

Browse files
committed
docs(specs): fold Drizzle v3 (PR #565) insights into the Prisma design
#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.
1 parent 0189c9c commit 2355bdb

1 file changed

Lines changed: 98 additions & 22 deletions

File tree

docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
6263
4. 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
```
68128
packages/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.
170243
2. This module's core (extension + where builders) against the interim
171-
full-envelope operand.
244+
full-envelope operand, mirroring #565's dialect *shape* only.
172245
3. 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

Comments
 (0)