From 0189c9ce3028638e145c9d7515f2fc0725867ccc Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 6 Jul 2026 16:59:01 +1000 Subject: [PATCH 1/3] =?UTF-8?q?docs(specs):=20EQL=20v3=20Prisma=20ORM=20in?= =?UTF-8?q?tegration=20design=20=E2=80=94=20CIP-3302?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../specs/2026-07-06-eql-v3-prisma-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md diff --git a/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md b/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md new file mode 100644 index 00000000..11770d03 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md @@ -0,0 +1,173 @@ +# EQL v3 Prisma ORM support — design + +Status: proposed (spike complete, findings below) +Date: 2026-07-06 +Ticket: CIP-3302 +Branch: `james/cip-3302-integrate-eql-v3-into-prisma-orm` + +## 1. Goal + +Add 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 (`src/eql/v3/drizzle`, PR #565). + +Success = a developer can declare encrypted columns for a Prisma model, feed +the same `@cipherstash/stack/eql/v3` schema to `EncryptionV3`, and run +equality / range / free-text queries through their Prisma client with +transparent encrypt-on-write / decrypt-on-read. + +Not to be confused with `@cipherstash/prisma-next` (PR #515) — the in-house +ORM is a separate track; nothing here touches it. + +## 2. Spike findings (2026-07-06) + +Environment: Prisma **7.8.0** + `@prisma/adapter-pg` (driver adapters are +mandatory in v7; `datasource.url` in `schema.prisma` is a validation error), +PG 17 with the vendored `eql_v3` bundle +(`packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql`). +Table: `spike_users (email eql_v3.text_eq, age eql_v3.integer_ord)`, Prisma +model declares both as `Json?`. Envelopes hand-crafted (domain CHECKs verify +key shape only; the `=` operator compares `hm` terms, so a same-`hm` / +different-`c` operand distinguishes domain-operator resolution from jsonb +structural equality). + +| # | Path | Result | +|---|------|--------| +| 1 | `create` with `Json` field → domain column | ✅ works (plain `INSERT … VALUES ($1,$2)`, assignment cast jsonb→domain) | +| 2 | `createMany` (valid envelopes) | ✅ works | +| 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. | +| 4 | CHECK-violating payload | ✅ rejected by PG (defense in depth holds) | +| 5 | `findMany` read-back | ✅ envelope round-trips as a JS object | +| 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.) | +| 7 | Typed `where` `gt/gte/lt/lte` on `Json` | ❌ rejected by Prisma client validation — not part of the Json filter surface | +| 8 | `$queryRaw` with native domain operator: `email = $1::jsonb` | ✅ matches via `eql_v3.eq` (the PostgREST/Supabase lowering) | +| 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) | +| 10 | `updateMany` with `Json` field | ✅ works | + +**Conclusions.** + +1. **Column strategy: `Json` fields over domain-typed DB columns.** Reads and + writes work transparently through the standard client; PG's assignment + cast + domain CHECK validate on the way in. `Unsupported("eql_v3.…")` + is rejected as primary strategy — it removes the field from the generated + client entirely. +2. **Typed `where` is a dead end for encrypted search** — equality is + silently wrong (6) and range doesn't exist (7). All encrypted filtering + must lower to raw SQL. This also means the integration must **guard** + against users passing encrypted columns to plain `where` (silent zero-row + results are the worst failure mode). +3. Both raw lowering styles work; prefer the **extracted-term dialect** + (shared with Drizzle v3) so the two integrations emit identical SQL and + the CIP-3402 term-envelope swap lands in one place. +4. Prisma 7's driver-adapter requirement shapes the docs/example app but not + the integration design ($extends and $queryRaw are unchanged). + +## 3. Architecture + +``` +packages/stack/src/eql/v3/prisma/ + index.ts // barrel: encryptedPrisma, whereEncrypted helpers, errors + extension.ts // $extends factory: encrypt-on-write / decrypt-on-read + model-map.ts // Prisma model name ↔ v3 encryptedTable registration + where.ts // capability-checked Prisma.sql fragment builders + sql-dialect.ts // extracted-term SQL emission (share/extract from drizzle v3 when #565 lands) + null-handling.ts // null → Prisma.DbNull normalization for encrypted fields +``` + +Exported at `@cipherstash/stack/eql/v3/prisma` (package.json `exports` + tsup +entry). Depends inward on `@/eql/v3` (concrete types = single source of truth +for domain / `cast_as` / capabilities) and `@/encryption` only. No dependency +on the v2 modules or on `@prisma/client` itself (structural typing over the +client, mirroring `SupabaseClientLike`). + +### 3.1 Write/read transparency: `$extends` query component + +`encryptedPrisma({ encryptionClient, prismaClient, tables })` returns +`prismaClient.$extends({ query: { $allModels: { … } } })` where `tables` maps +Prisma model names to v3 `encryptedTable` schemas. + +- **Writes** (`create`, `update`, `upsert`, `createMany`, `createManyAndReturn`, + `updateMany`): plaintext values on registered encrypted fields are encrypted + via `encryptModel` / `bulkEncryptModels`; `null` becomes `Prisma.DbNull` + (finding 3). +- **Reads** (`findMany`, `findFirst`, `findUnique`, and the `*OrThrow` + variants, plus the rows returned by mutating calls): envelopes on registered + fields are decrypted via `bulkDecryptModels`, with `Date` reconstruction + from `cast_as` and native `bigint` passthrough (parity with + `src/encryption/v3.ts`). +- **Guard**: any registered encrypted field appearing inside `where` / + `orderBy` / `distinct` of an intercepted call throws + `PrismaEncryptedColumnError` — never silently returns wrong results + (finding 6). Plaintext (unregistered) fields pass through untouched. +- Lock context + audit config passthrough, same surface as the Supabase + builder (`withLockContext`, `audit`). + +### 3.2 Encrypted filtering: `Prisma.sql` fragment builders + +Typed `where` cannot express encrypted search, so filtering is explicit: + +```ts +const { whereEq, whereGt, whereMatch } = encryptedWhere(client, users) + +const rows = await eprisma.$queryRawEncrypted( + users, + Prisma.sql`SELECT * FROM users WHERE ${await whereEq(users.email, 'a@b.com')} AND plan = ${plan}`, +) +``` + +- Each builder is **capability-checked** against + `column.getQueryCapabilities()` (storage-only columns and + operator/capability mismatches throw — runtime guard now, type-level + narrowing like `V3FilterableKeys` where feasible). +- Operand encryption reuses the **interim full-envelope encoding** with the + same single-swap-point discipline as the Supabase builder + (`query-builder-v3.ts#encryptCollectedTerms`, CIP-3402) — or `encryptQuery` + terms if #565's encrypt-query path lands first; align with whichever ships. +- SQL emission is the extracted-term dialect (finding 9), using the two-arg + function forms (`eql_v3.eq(col, $::jsonb)`) to avoid the double-cast + wrinkle. +- Free-text: bloom containment (`match_term @> bloom_filter`), documented as + token match, not SQL `LIKE` (same caveat as Drizzle/Supabase). +- `$queryRawEncrypted(table, sql)` wraps `$queryRaw` and decrypts the result + rows against the table schema. + +### 3.3 DDL / migration story + +`prisma migrate` emits `jsonb` for `Json` fields; the DB column must be the +domain. Ship documented, copy-pasteable migration SQL +(`ALTER TABLE … ALTER COLUMN … TYPE eql_v3.text_eq USING …` or authoring the +column as the domain in the initial migration — Prisma migrations are plain +SQL files, so hand-edits are first-class) plus the `eql_v3` bundle install +(reuse `scripts/install-eql-v3.ts` / CLI installer). A codegen step that +patches migrations automatically is a follow-up, not v1. + +## 4. Scope + +### In scope +- The module above, for every shipped scalar domain (text/int/float/numeric/ + date/timestamp/bool families; bigint rides in with #557's release gate). +- Unit tests with a mocked Prisma client (mirror the mock-Supabase pattern), + `.test-d.ts` type tests, capability-mismatch matrix, live-gated PG tests + (`DATABASE_URL` + `CS_*`, reuse `__tests__/helpers/live-gate.ts`). +- Example app under `examples/` (note `examples/prisma` belongs to + prisma-next; new directory, e.g. `examples/prisma-orm`), Prisma 7 + + driver-adapter shaped. +- README + `docs/query-api-walkthrough.md` sections, changeset (minor). + +### Out of scope +- JSON / `ste_vec` columns (no v3 JSON builder exists yet). +- Encrypted `ORDER BY` through the typed API (raw-SQL fragment at most). +- Automatic migration patching (documented manual edit in v1). +- Prisma < 7 compatibility testing (the `::jsonb` column cast predates v7, so + the typed-where conclusion holds for v6; the extension targets the + client-extension API, GA since v4.16 — verify on v6 opportunistically). +- Any change to `@cipherstash/prisma-next` or the v2 integrations. + +## 5. Sequencing + +1. **#565 (Drizzle v3)** first if possible — shares the encrypt-query path + and the term-function SQL dialect this module wants to extract/reuse. +2. This module's core (extension + where builders) against the interim + full-envelope operand. +3. CIP-3402 lands the term-only envelope → swap inside the one operand- + encryption method. From 2355bdb01cbdf86605454622f28ce4d22d347426 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 6 Jul 2026 21:38:14 +1000 Subject: [PATCH 2/3] docs(specs): fold Drizzle v3 (PR #565) insights into the Prisma design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .../specs/2026-07-06-eql-v3-prisma-design.md | 120 ++++++++++++++---- 1 file changed, 98 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md b/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md index 11770d03..55406eeb 100644 --- a/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md +++ b/docs/superpowers/specs/2026-07-06-eql-v3-prisma-design.md @@ -56,12 +56,72 @@ structural equality). must lower to raw SQL. This also means the integration must **guard** against users passing encrypted columns to plain `where` (silent zero-row results are the worst failure mode). -3. Both raw lowering styles work; prefer the **extracted-term dialect** - (shared with Drizzle v3) so the two integrations emit identical SQL and - the CIP-3402 term-envelope swap lands in one place. +3. Both raw lowering styles work on main's bundle. Use the **two-arg + function forms** (`eql_v3.eq(col, $::jsonb)` etc.) with full-envelope + operands; emitting Drizzle-v3-identical SQL is not achievable today + because #565 targets a different bundle generation (see §2b). 4. Prisma 7's driver-adapter requirement shapes the docs/example app but not the integration design ($extends and $queryRaw are unchanged). +## 2b. Insights from the Drizzle v3 implementation (PR #565, reviewed 2026-07-06) + +#565 landed a full implementation of the sibling integration. What transfers, +what doesn't, and what it changes here: + +**Bundle-generation split — the biggest caution.** #565 is built on the +**protect-ffi 0.26** line: pre-rename domain names (`eql_v3.int4_ord`), and a +bundle that exposes **public** term constructors +(`eql_v3.hmac_256/ore_block_256/bloom_filter`) accepting term-only jsonb; on +that line `client.encryptQuery` produces v3 scalar terms. Main — this +module's base — is the **protect-ffi 0.27** line: SQL-standard domain names +(`eql_v3.integer_ord`), constructors in **`eql_v3_internal`** only, public +two-arg wrappers (`eql_v3.eq(col, $::jsonb)`) that **coerce the operand into +the domain** (so it must be a full envelope passing the CHECK), and scalar +`encryptQuery` throwing `EQL_V3_QUERY_UNSUPPORTED`. Consequences: + +- #565's exact SQL (`eql_v3.hmac_256(…)`) and its term-operand path **do not + work against main's bundle**. This module keeps the full-envelope operand + (spike-verified via both the native domain operator and the + `eq_term(col) = eq_term($::jsonb::domain)` form, which the two-arg + `eql_v3.eq(col, $::jsonb)` wrapper is equivalent to). +- Code-sharing #565's `sql-dialect.ts` is off the table for now — it is + drizzle-`SQL`-typed and pinned to the other bundle generation. Share the + **shape** (the dialect table below), not the module; extracting a + driver-neutral dialect is a follow-up once both integrations sit on one + bundle generation. +- Sequencing flips: #565 must reconcile with main's 0.27/SQL-standard-name + line before anything can be shared. This module should **not block on it**. + +**What does transfer (adopt directly):** + +1. **The dialect shape.** A small object keyed off `builder.build().indexes` + (`unique`/`ore`/`match` — the authoritative index set, which #565 uses for + gating instead of `getQueryCapabilities()`; align on that here too), with: + equality (`unique` → hmac path), **equality-via-ORE fallback** for + ord-only columns (`ord_term = ore-constructor`, `queryType: + 'orderAndRange'`), comparison, `between`/`notBetween`, match, and + `orderBy`. +2. **Encrypted `ORDER BY` is in scope** for raw-SQL integrations: `ORDER BY + eql_v3.ord_term(col)` (requires `ore`). #565 ships `asc`/`desc`; this + module ships an `orderByEncrypted` fragment builder. (Supersedes the + earlier out-of-scope entry.) +3. **`inArray`/`notInArray`** as OR-of-equality / AND-of-inequality terms, + encrypting operands with **bounded concurrency** (#565 caps at 4) — adopt + for `whereIn`/`whereNotIn` builders. +4. **No-silent-fallback errors.** v3 operators throw a named error + (`EncryptionOperatorError`) carrying `{ columnName, tableName, operator }` + when a column lacks the required index or isn't a v3 column at all — no + fall-through to plain operators (v2 behaviour), because wrong-operator SQL + fails the domain CHECK at runtime anyway. Matches and strengthens this + design's typed-`where` guard; adopt the same error-context convention. +5. **Null codec confirmation.** #565's `toDriver` maps `null`/`undefined` → + SQL NULL for exactly the domain-CHECK reason spike finding 3 hit. Here + only `null → Prisma.DbNull` transfers: Prisma's `undefined` means "field + not provided" and must be left untouched (coercing it would null out + columns on update — a Drizzle-codec detail that does not carry over). +6. **Per-table schema cache** (WeakMap keyed by the ORM table object) for + resolving a column's owning `encryptedTable` — mirror in `model-map.ts`. + ## 3. Architecture ``` @@ -69,8 +129,8 @@ packages/stack/src/eql/v3/prisma/ index.ts // barrel: encryptedPrisma, whereEncrypted helpers, errors extension.ts // $extends factory: encrypt-on-write / decrypt-on-read model-map.ts // Prisma model name ↔ v3 encryptedTable registration - where.ts // capability-checked Prisma.sql fragment builders - sql-dialect.ts // extracted-term SQL emission (share/extract from drizzle v3 when #565 lands) + where.ts // capability-checked Prisma.sql fragment builders (incl. whereIn, orderByEncrypted) + sql-dialect.ts // Prisma.sql emission; mirrors #565's dialect SHAPE (code-sharing blocked by the bundle split, §2b) null-handling.ts // null → Prisma.DbNull normalization for encrypted fields ``` @@ -89,7 +149,9 @@ Prisma model names to v3 `encryptedTable` schemas. - **Writes** (`create`, `update`, `upsert`, `createMany`, `createManyAndReturn`, `updateMany`): plaintext values on registered encrypted fields are encrypted via `encryptModel` / `bulkEncryptModels`; `null` becomes `Prisma.DbNull` - (finding 3). + (finding 3; #565's codec confirms the domain-CHECK rationale, §2b). + `undefined` is left untouched — in Prisma it means "field not provided", + and coercing it to `DbNull` would null out columns on update. - **Reads** (`findMany`, `findFirst`, `findUnique`, and the `*OrThrow` variants, plus the rows returned by mutating calls): envelopes on registered fields are decrypted via `bulkDecryptModels`, with `Date` reconstruction @@ -115,19 +177,27 @@ const rows = await eprisma.$queryRawEncrypted( ) ``` -- Each builder is **capability-checked** against - `column.getQueryCapabilities()` (storage-only columns and - operator/capability mismatches throw — runtime guard now, type-level - narrowing like `V3FilterableKeys` where feasible). +- Each builder is **capability-checked** against `builder.build().indexes` + (`unique`/`ore`/`match` — the authoritative index set, per #565; see §2b). + Storage-only columns and operator/capability mismatches throw a named error + with `{ columnName, tableName, operator }` context — runtime guard now, + type-level narrowing like `V3FilterableKeys` where feasible. Equality on an + ord-only column falls back to ORE (`queryType: 'orderAndRange'`), mirroring + #565. - Operand encryption reuses the **interim full-envelope encoding** with the same single-swap-point discipline as the Supabase builder - (`query-builder-v3.ts#encryptCollectedTerms`, CIP-3402) — or `encryptQuery` - terms if #565's encrypt-query path lands first; align with whichever ships. -- SQL emission is the extracted-term dialect (finding 9), using the two-arg - function forms (`eql_v3.eq(col, $::jsonb)`) to avoid the double-cast - wrinkle. -- Free-text: bloom containment (`match_term @> bloom_filter`), documented as - token match, not SQL `LIKE` (same caveat as Drizzle/Supabase). + (`query-builder-v3.ts#encryptCollectedTerms`, CIP-3402). #565's + `encryptQuery` term path does NOT transfer to main's protect-ffi 0.27 line + (§2b) — revisit only when the term-only envelope ships, and note the + lowering must switch away from the domain-coercing forms at the same time + (a term-only operand fails the domain CHECK). +- SQL emission uses the two-arg function forms (`eql_v3.eq(col, $::jsonb)`, + finding 9 equivalent) to avoid the double-cast wrinkle. `whereIn` = OR of + equality fragments with bounded operand-encryption concurrency; + `orderByEncrypted` = `eql_v3.ord_term(col)` (requires `ore`). +- Free-text: bloom containment via the two-arg `eql_v3.contains(col, + $::jsonb)` (equivalently the native `@>` operator Supabase's `cs` uses), + documented as token match, not SQL `LIKE` (same caveat as Drizzle/Supabase). - `$queryRawEncrypted(table, sql)` wraps `$queryRaw` and decrypts the result rows against the table schema. @@ -156,7 +226,8 @@ patches migrations automatically is a follow-up, not v1. ### Out of scope - JSON / `ste_vec` columns (no v3 JSON builder exists yet). -- Encrypted `ORDER BY` through the typed API (raw-SQL fragment at most). +- Encrypted `ORDER BY` through the **typed** API (`orderBy: {…}`) — but the + raw-SQL `orderByEncrypted` fragment builder IS in scope (§2b insight 2). - Automatic migration patching (documented manual edit in v1). - Prisma < 7 compatibility testing (the `::jsonb` column cast predates v7, so the typed-where conclusion holds for v6; the extension targets the @@ -165,9 +236,14 @@ patches migrations automatically is a follow-up, not v1. ## 5. Sequencing -1. **#565 (Drizzle v3)** first if possible — shares the encrypt-query path - and the term-function SQL dialect this module wants to extract/reuse. +1. **Do not block on #565.** It is built on the protect-ffi 0.26 / + pre-rename-domain line and must reconcile with main's 0.27 / + SQL-standard-name line before its dialect or encrypt-query path can be + shared (§2b). This module proceeds against main directly. 2. This module's core (extension + where builders) against the interim - full-envelope operand. + full-envelope operand, mirroring #565's dialect *shape* only. 3. CIP-3402 lands the term-only envelope → swap inside the one operand- - encryption method. + encryption method AND switch the lowering off the domain-coercing forms + in the same change (term-only operands fail the domain CHECK). +4. Follow-up once both integrations share a bundle generation: extract a + driver-neutral term-SQL dialect. From e1833a4a26161330cc656990f6ff39d2ab03edcf Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 6 Jul 2026 22:07:09 +1000 Subject: [PATCH 3/3] =?UTF-8?q?feat(stack):=20EQL=20v3=20Prisma=20ORM=20in?= =?UTF-8?q?tegration=20core=20=E2=80=94=20CIP-3302?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .changeset/eql-v3-prisma.md | 27 ++ packages/stack/__tests__/cjs-require.test.ts | 12 + .../__tests__/prisma-v3/extension.test.ts | 269 ++++++++++++++++ .../stack/__tests__/prisma-v3/factory.test.ts | 137 ++++++++ packages/stack/__tests__/prisma-v3/mocks.ts | 238 ++++++++++++++ .../__tests__/prisma-v3/model-map.test.ts | 129 ++++++++ .../stack/__tests__/prisma-v3/types.test-d.ts | 44 +++ .../stack/__tests__/prisma-v3/where.test.ts | 258 +++++++++++++++ packages/stack/package.json | 10 + packages/stack/src/eql/v3/prisma/errors.ts | 58 ++++ packages/stack/src/eql/v3/prisma/extension.ts | 289 +++++++++++++++++ packages/stack/src/eql/v3/prisma/index.ts | 144 +++++++++ packages/stack/src/eql/v3/prisma/model-map.ts | 120 +++++++ .../stack/src/eql/v3/prisma/sql-dialect.ts | 89 ++++++ packages/stack/src/eql/v3/prisma/types.ts | 49 +++ packages/stack/src/eql/v3/prisma/where.ts | 298 ++++++++++++++++++ packages/stack/tsup.config.ts | 1 + 17 files changed, 2172 insertions(+) create mode 100644 .changeset/eql-v3-prisma.md create mode 100644 packages/stack/__tests__/prisma-v3/extension.test.ts create mode 100644 packages/stack/__tests__/prisma-v3/factory.test.ts create mode 100644 packages/stack/__tests__/prisma-v3/mocks.ts create mode 100644 packages/stack/__tests__/prisma-v3/model-map.test.ts create mode 100644 packages/stack/__tests__/prisma-v3/types.test-d.ts create mode 100644 packages/stack/__tests__/prisma-v3/where.test.ts create mode 100644 packages/stack/src/eql/v3/prisma/errors.ts create mode 100644 packages/stack/src/eql/v3/prisma/extension.ts create mode 100644 packages/stack/src/eql/v3/prisma/index.ts create mode 100644 packages/stack/src/eql/v3/prisma/model-map.ts create mode 100644 packages/stack/src/eql/v3/prisma/sql-dialect.ts create mode 100644 packages/stack/src/eql/v3/prisma/types.ts create mode 100644 packages/stack/src/eql/v3/prisma/where.ts diff --git a/.changeset/eql-v3-prisma.md b/.changeset/eql-v3-prisma.md new file mode 100644 index 00000000..3d09eb36 --- /dev/null +++ b/.changeset/eql-v3-prisma.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack': minor +--- + +Add the EQL v3 Prisma ORM integration (`@cipherstash/stack/eql/v3/prisma`). + +`encryptedPrisma({ encryptionClient, prismaClient, prisma, tables })` returns: + +- `client` — the `$extends`-wrapped Prisma client: models registered in + `tables` transparently encrypt on write (`create`/`update`/`upsert`/ + `createMany`/`updateMany` + `AndReturn` variants, with `null` normalized to + `Prisma.DbNull` so the `eql_v3` domain CHECK is satisfied) and decrypt on + read (including `Date` reconstruction). Encrypted columns referenced + through the typed `where`/`orderBy`/`distinct`/`cursor`/`having` surface + throw `PrismaEncryptedColumnError` — Prisma's Json lowering casts the + column side to jsonb, bypassing the `eql_v3` operators, so a typed filter + would silently match nothing. +- `where` — capability-checked `Prisma.sql` fragment builders (`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, tracked by + CIP-3402/CIP-3423). +- `$queryRawEncrypted(table, sql)` — runs a raw query and decrypts the + result rows. + +Declare encrypted columns as `Json` fields in `schema.prisma`; the DB +columns are the `eql_v3.*` domains (edit the generated migration SQL). diff --git a/packages/stack/__tests__/cjs-require.test.ts b/packages/stack/__tests__/cjs-require.test.ts index 0b530fd5..d2c02485 100644 --- a/packages/stack/__tests__/cjs-require.test.ts +++ b/packages/stack/__tests__/cjs-require.test.ts @@ -84,6 +84,18 @@ describe('CJS consumers can require the built bundles', () => { expect(cjsEntries).toContain('dist/index.cjs') expect(cjsEntries).toContain('dist/encryption/index.cjs') expect(cjsEntries).toContain('dist/eql/v3/index.cjs') + expect(cjsEntries).toContain('dist/eql/v3/prisma/index.cjs') + }) + + it('exposes the prisma integration from the CJS bundle', () => { + const prismaBundle = path.join(distDir, 'eql', 'v3', 'prisma', 'index.cjs') + const script = [ + `const prisma = require(${JSON.stringify(prismaBundle)})`, + `if (typeof prisma.encryptedPrisma !== 'function') { throw new Error('missing prisma CJS export: encryptedPrisma') }`, + `if (typeof prisma.createEncryptedWhere !== 'function') { throw new Error('missing prisma CJS export: createEncryptedWhere') }`, + `if (typeof prisma.PrismaEncryptedColumnError !== 'function') { throw new Error('missing prisma CJS export: PrismaEncryptedColumnError') }`, + ].join('\n') + execFileSync(process.execPath, ['-e', script]) }) it('exposes the v3 `types` namespace + table API from the CJS bundle', () => { diff --git a/packages/stack/__tests__/prisma-v3/extension.test.ts b/packages/stack/__tests__/prisma-v3/extension.test.ts new file mode 100644 index 00000000..252f6e16 --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/extension.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import { + PrismaEncryptedColumnError, + PrismaEncryptionError, +} from '@/eql/v3/prisma/errors' +import { createEncryptedExtension } from '@/eql/v3/prisma/extension' +import { buildModelMap } from '@/eql/v3/prisma/model-map' +import { + createFailingEncryptionClient, + createFakePrismaClient, + createMockEncryptionClient, + fakeEnvelope, + fakePrismaNamespace, + isFakeEnvelope, + PrismaDbNull, +} from './mocks' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + age: types.IntegerOrd('age'), + createdOn: types.TimestampOrd('created_on'), +}) + +type ExtendedHarness = { + run( + model: string, + operation: string, + args: Record, + ): Promise +} + +function setup(options?: { + encryption?: ReturnType + baseQuery?: (call: { + model: string + operation: string + args: Record + }) => unknown + config?: { lockContext?: unknown; audit?: unknown } +}) { + const encryption = options?.encryption ?? createMockEncryptionClient() + const seen: Array<{ + model: string + operation: string + args: Record + }> = [] + const { client } = createFakePrismaClient((call) => { + seen.push(call) + return options?.baseQuery ? options.baseQuery(call) : null + }) + const { byModel } = buildModelMap({ User: users }) + const extension = createEncryptedExtension({ + encryptionClient: encryption.client, + prisma: fakePrismaNamespace, + byModel, + lockContext: options?.config?.lockContext as never, + audit: options?.config?.audit as never, + }) + const extended = client.$extends( + extension as Parameters[0], + ) as unknown as ExtendedHarness + return { extended, seen, calls: encryption.calls } +} + +describe('write path', () => { + it('encrypts registered fields on create and passes others through', async () => { + const { extended, seen } = setup() + await extended.run('User', 'create', { + data: { email: 'a@b.com', age: 30, plan: 'pro' }, + }) + const sent = seen[0].args.data as Record + expect(isFakeEnvelope(sent.email)).toBe(true) + expect(isFakeEnvelope(sent.age)).toBe(true) + expect(sent.plan).toBe('pro') + }) + + it('normalizes null encrypted fields to Prisma.DbNull', async () => { + const { extended, seen } = setup() + await extended.run('User', 'create', { + data: { email: null, plan: 'free' }, + }) + const sent = seen[0].args.data as Record + expect(sent.email).toBe(PrismaDbNull) + expect(sent.plan).toBe('free') + }) + + it('leaves absent and undefined fields untouched (Prisma "not provided")', async () => { + const { extended, seen } = setup() + await extended.run('User', 'update', { + where: { id: 1 }, + data: { email: 'x@y.z', age: undefined }, + }) + const sent = seen[0].args.data as Record + expect(isFakeEnvelope(sent.email)).toBe(true) + expect(sent.age).toBeUndefined() + expect(sent.age !== PrismaDbNull).toBe(true) + }) + + it('bulk-encrypts createMany array data', async () => { + const { extended, seen } = setup() + await extended.run('User', 'createMany', { + data: [{ email: 'a@a.com' }, { email: null }], + }) + const sent = seen[0].args.data as Record[] + expect(isFakeEnvelope(sent[0].email)).toBe(true) + expect(sent[1].email).toBe(PrismaDbNull) + }) + + it('handles createMany with a single object', async () => { + const { extended, seen } = setup() + await extended.run('User', 'createMany', { + data: { email: 'a@a.com' }, + }) + const sent = seen[0].args.data as Record + expect(isFakeEnvelope(sent.email)).toBe(true) + }) + + it('encrypts both branches of upsert', async () => { + const { extended, seen } = setup() + await extended.run('User', 'upsert', { + where: { id: 1 }, + create: { email: 'a@a.com' }, + update: { email: 'b@b.com' }, + }) + const args = seen[0].args as Record> + expect(isFakeEnvelope(args.create.email)).toBe(true) + expect(isFakeEnvelope(args.update.email)).toBe(true) + }) + + it('wraps encryption failures in PrismaEncryptionError', async () => { + const { client } = createFakePrismaClient(() => null) + const { byModel } = buildModelMap({ User: users }) + const extension = createEncryptedExtension({ + encryptionClient: createFailingEncryptionClient(), + prisma: fakePrismaNamespace, + byModel, + }) + const extended = client.$extends( + extension as Parameters[0], + ) as unknown as ExtendedHarness + await expect( + extended.run('User', 'create', { data: { email: 'a@a.com' } }), + ).rejects.toThrow(PrismaEncryptionError) + }) +}) + +describe('typed-query guard', () => { + it.each([ + ['where', { where: { email: { equals: 'x' } } }], + ['where', { where: { email: 'x' } }], + ['nested AND', { where: { AND: [{ email: 'x' }] } }], + ['nested OR of NOT', { where: { OR: [{ NOT: { email: 'x' } }] } }], + ['orderBy object', { orderBy: { email: 'asc' } }], + ['orderBy array', { orderBy: [{ email: 'asc' }] }], + ['distinct', { distinct: ['email'] }], + ['cursor', { cursor: { email: 'x' } }], + ])('throws when an encrypted field appears in %s', async (_label, args) => { + const { extended } = setup() + await expect(extended.run('User', 'findMany', args)).rejects.toThrow( + PrismaEncryptedColumnError, + ) + }) + + it('allows plaintext fields in where/orderBy/distinct', async () => { + const { extended, seen } = setup() + await extended.run('User', 'findMany', { + where: { plan: 'pro', AND: [{ id: { gt: 3 } }] }, + orderBy: { id: 'desc' }, + distinct: ['plan'], + }) + expect(seen).toHaveLength(1) + }) + + it('does not recurse into relation filter objects', async () => { + // `posts` is a relation whose OWN model may have a field named `email` — + // that is a different column and must not trip this table's guard. + const { extended, seen } = setup() + await extended.run('User', 'findMany', { + where: { posts: { some: { email: 'x' } } }, + }) + expect(seen).toHaveLength(1) + }) + + it('guards where on count/aggregate but passes their result through', async () => { + const { extended } = setup({ baseQuery: () => ({ _count: 3 }) }) + await expect( + extended.run('User', 'count', { where: { email: 'x' } }), + ).rejects.toThrow(PrismaEncryptedColumnError) + const result = await extended.run('User', 'count', { + where: { plan: 'pro' }, + }) + expect(result).toEqual({ _count: 3 }) + }) +}) + +describe('read path', () => { + const dbRow = () => ({ + id: 1, + email: fakeEnvelope('a@b.com', 'email'), + createdOn: fakeEnvelope(new Date('2026-01-02T03:04:05.000Z'), 'created_on'), + plan: 'pro', + }) + + it('decrypts array results and reconstructs dates', async () => { + const { extended } = setup({ baseQuery: () => [dbRow()] }) + const rows = (await extended.run('User', 'findMany', {})) as Record< + string, + unknown + >[] + expect(rows[0].email).toBe('a@b.com') + expect(rows[0].createdOn).toBeInstanceOf(Date) + expect((rows[0].createdOn as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + expect(rows[0].plan).toBe('pro') + }) + + it('decrypts single-object results (create/findUnique)', async () => { + const { extended } = setup({ baseQuery: () => dbRow() }) + const row = (await extended.run('User', 'create', { + data: { plan: 'pro' }, + })) as Record + expect(row.email).toBe('a@b.com') + }) + + it('passes null results through (findFirst miss)', async () => { + const { extended } = setup({ baseQuery: () => null }) + expect(await extended.run('User', 'findFirst', {})).toBeNull() + }) + + it('passes batch payloads through untouched (createMany)', async () => { + const { extended } = setup({ baseQuery: () => ({ count: 2 }) }) + const result = await extended.run('User', 'createMany', { + data: [{ email: 'a@a.com' }], + }) + expect(result).toEqual({ count: 2 }) + }) +}) + +describe('model routing and plumbing', () => { + it('passes unregistered models through untouched', async () => { + const { extended, seen } = setup() + const args = { + data: { email: 'plain@text.com' }, + where: { email: 'plain@text.com' }, + } + await extended.run('Post', 'create', args) + expect(seen[0].args).toBe(args) + expect((seen[0].args as { data: Record }).data.email).toBe( + 'plain@text.com', + ) + }) + + it('applies config lockContext and audit to encrypt/decrypt operations', async () => { + const lockContext = { kind: 'lc' } + const audit = { metadata: { actor: 'ext' } } + const encryption = createMockEncryptionClient() + const { extended, calls } = setup({ + encryption, + baseQuery: () => ({ id: 1, email: fakeEnvelope('a@b.com', 'email') }), + config: { lockContext, audit }, + }) + await extended.run('User', 'create', { data: { email: 'a@b.com' } }) + // one encryptModel + one decryptModel, each with both applied + expect(calls.lockContexts).toEqual([lockContext, lockContext]) + expect(calls.audits).toEqual([audit, audit]) + }) +}) diff --git a/packages/stack/__tests__/prisma-v3/factory.test.ts b/packages/stack/__tests__/prisma-v3/factory.test.ts new file mode 100644 index 00000000..a5326ae7 --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/factory.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import { encryptedPrisma } from '@/eql/v3/prisma' +import { + type CapturedSql, + createFakePrismaClient, + createMockEncryptionClient, + fakeEnvelope, + fakePrismaNamespace, + isFakeEnvelope, + renderSql, +} from './mocks' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + createdOn: types.TimestampOrd('created_on'), +}) + +const audits = encryptedTable('audits', { + actor: types.TextEq('actor'), +}) + +function setup(baseQuery: Parameters[0]) { + const encryption = createMockEncryptionClient() + const fake = createFakePrismaClient(baseQuery) + const instance = encryptedPrisma({ + encryptionClient: encryption.client, + prismaClient: fake.client, + prisma: fakePrismaNamespace, + tables: { User: users }, + }) + return { instance, fake, calls: encryption.calls } +} + +describe('encryptedPrisma factory', () => { + it('returns the extended client, where builders, and $queryRawEncrypted', () => { + const { instance } = setup(() => null) + expect(instance.client).toMatchObject({ __extended: true }) + expect(typeof instance.where.eq).toBe('function') + expect(typeof instance.$queryRawEncrypted).toBe('function') + }) + + it('the extension and where builders share the registration', async () => { + const { instance } = setup(() => null) + const frag = (await instance.where.eq( + users.email, + 'a@b.com', + )) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.eq("email", $1::jsonb)') + }) +}) + +describe('$queryRawEncrypted', () => { + const dbRows = () => [ + { + id: 1, + email: fakeEnvelope('a@b.com', 'email'), + created_on: fakeEnvelope( + new Date('2026-01-02T03:04:05.000Z'), + 'created_on', + ), + plan: 'pro', + }, + ] + + it('passes the fragment to $queryRaw and decrypts db-name-keyed rows', async () => { + const { instance, fake } = setup(() => dbRows()) + const query = fakePrismaNamespace.sql(['SELECT * FROM users']) + const rows = await instance.$queryRawEncrypted(users, query) + expect(fake.captured.rawQueries).toEqual([query]) + expect(rows[0].email).toBe('a@b.com') + expect(rows[0].plan).toBe('pro') + }) + + it('reconstructs dates under the db column name', async () => { + const { instance } = setup(() => dbRows()) + const rows = await instance.$queryRawEncrypted( + users, + fakePrismaNamespace.sql(['SELECT * FROM users']), + ) + expect(rows[0].created_on).toBeInstanceOf(Date) + expect((rows[0].created_on as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + }) + + it('decrypts against an unregistered table schema too', async () => { + const { instance } = setup(() => [{ actor: fakeEnvelope('root', 'actor') }]) + const rows = await instance.$queryRawEncrypted( + audits, + fakePrismaNamespace.sql(['SELECT * FROM audits']), + ) + expect(rows[0].actor).toBe('root') + }) + + it('passes per-call lockContext and audit to the decrypt operation', async () => { + const { instance, calls } = setup(() => dbRows()) + const lockContext = { kind: 'lc' } + const audit = { metadata: { actor: 'raw' } } + await instance.$queryRawEncrypted( + users, + fakePrismaNamespace.sql(['SELECT * FROM users']), + { lockContext: lockContext as never, audit }, + ) + expect(calls.lockContexts).toEqual([lockContext]) + expect(calls.audits).toEqual([audit]) + }) + + it('returns non-row results untouched', async () => { + const { instance } = setup(() => []) + const rows = await instance.$queryRawEncrypted( + users, + fakePrismaNamespace.sql(['SELECT * FROM users WHERE 1=0']), + ) + expect(rows).toEqual([]) + }) +}) + +describe('end-to-end write through the extended client', () => { + it('routes model operations through the extension', async () => { + const seen: Array<{ args: Record }> = [] + const { instance } = setup((call) => { + seen.push(call) + return null + }) + const harness = instance.client as unknown as { + run( + model: string, + operation: string, + args: Record, + ): Promise + } + await harness.run('User', 'create', { data: { email: 'a@b.com' } }) + const sent = seen[0].args.data as Record + expect(isFakeEnvelope(sent.email)).toBe(true) + }) +}) diff --git a/packages/stack/__tests__/prisma-v3/mocks.ts b/packages/stack/__tests__/prisma-v3/mocks.ts new file mode 100644 index 00000000..492910b9 --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/mocks.ts @@ -0,0 +1,238 @@ +import type { EncryptionClient } from '@/encryption' + +// --------------------------------------------------------------------------- +// Shared mocks for the prisma-v3 suites. +// +// The integration only touches a narrow slice of the encryption client and +// the Prisma client, so both are simulated: the encryption mock produces +// deterministic fake envelopes (carrying the plaintext in `pt` so the fake +// decrypt can undo them), and the Prisma mocks record every call. This pins +// the WIRE ENCODING the integration produces — the part CI can verify +// without a live Postgres + ZeroKMS. +// --------------------------------------------------------------------------- + +export type FakeEnvelope = { + v: 3 + i: { t: string; c: string } + c: string + hm: string + pt: unknown +} + +export function fakeEnvelope(value: unknown, column: string): FakeEnvelope { + const pt = value instanceof Date ? value.toISOString() : value + return { + v: 3, + i: { t: 'tbl', c: column }, + c: `ct:${String(pt)}`, + hm: `hm:${String(pt)}`, + pt, + } +} + +export function isFakeEnvelope(value: unknown): value is FakeEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'pt' in value && + 'c' in value && + 'hm' in value + ) +} + +/** A chainable operation resolving to `{ data }`, like the real ones. */ +function operation(data: T, calls?: OperationCalls) { + const op = { + withLockContext: (lockContext: unknown) => { + calls?.lockContexts.push(lockContext) + return op + }, + audit: (config: unknown) => { + calls?.audits.push(config) + return op + }, + then: ( + onfulfilled?: ((value: { data: T }) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => Promise.resolve({ data }).then(onfulfilled, onrejected), + } + return op +} + +export type OperationCalls = { + lockContexts: unknown[] + audits: unknown[] +} + +type SchemaLike = { + build(): { columns: Record } + buildColumnKeyMap(): Record +} + +/** + * Deterministic mock of the slice of {@link EncryptionClient} the prisma + * integration consumes. Records lock contexts and audit configs passed to any + * operation in `calls`. + */ +export function createMockEncryptionClient() { + const calls: OperationCalls & { encrypted: unknown[] } = { + lockContexts: [], + audits: [], + encrypted: [], + } + + const encryptedProps = (table: SchemaLike): string[] => + Object.keys(table.buildColumnKeyMap()) + + const dbName = (table: SchemaLike, prop: string): string => + table.buildColumnKeyMap()[prop] ?? prop + + const encryptModel = (model: Record, table: SchemaLike) => { + const props = encryptedProps(table) + const out: Record = { ...model } + for (const prop of props) { + if (!(prop in model)) continue + const value = model[prop] + out[prop] = + value == null ? null : fakeEnvelope(value, dbName(table, prop)) + } + return out + } + + const decryptModel = (model: Record) => { + const out: Record = { ...model } + for (const [key, value] of Object.entries(model)) { + if (isFakeEnvelope(value)) out[key] = value.pt + } + return out + } + + const client = { + encrypt: (value: unknown, opts: { column: { getName(): string } }) => { + const envelope = + value == null ? null : fakeEnvelope(value, opts.column.getName()) + calls.encrypted.push(envelope) + return operation(envelope, calls) + }, + encryptModel: (model: Record, table: SchemaLike) => + operation(encryptModel(model, table), calls), + bulkEncryptModels: (models: Record[], table: SchemaLike) => + operation( + models.map((m) => encryptModel(m, table)), + calls, + ), + decryptModel: (model: Record) => + operation(decryptModel(model), calls), + bulkDecryptModels: (models: Record[]) => + operation(models.map(decryptModel), calls), + } + + return { client: client as unknown as EncryptionClient, calls } +} + +/** An encryption client whose every operation resolves to a failure. */ +export function createFailingEncryptionClient(message = 'boom') { + const failed = () => ({ + withLockContext: () => failed(), + audit: () => failed(), + then: (onfulfilled?: ((value: unknown) => unknown) | null) => + Promise.resolve({ failure: { message } }).then(onfulfilled), + }) + const client = { + encrypt: failed, + encryptModel: failed, + bulkEncryptModels: failed, + decryptModel: failed, + bulkDecryptModels: failed, + } + return client as unknown as EncryptionClient +} + +// --------------------------------------------------------------------------- +// Prisma mocks +// --------------------------------------------------------------------------- + +/** + * Minimal stand-in for the generated client's `Prisma` namespace: `sql` + * captures the template strings + values into an inspectable object, and + * `DbNull` is a unique sentinel the tests can assert on by identity. + */ +export const PrismaDbNull = Symbol('Prisma.DbNull') + +export type CapturedSql = { strings: ReadonlyArray; values: unknown[] } + +export const fakePrismaNamespace = { + sql: (strings: ReadonlyArray, ...values: unknown[]): CapturedSql => ({ + strings, + values, + }), + DbNull: PrismaDbNull, +} + +/** Render a captured fragment to a flat SQL string with `$n` placeholders. */ +export function renderSql(fragment: CapturedSql): string { + return fragment.strings.reduce( + (acc, part, i) => (i === 0 ? part : `${acc}$${i}${part}`), + '', + ) +} + +export type QueryHookArgs = { + model: string + operation: string + args: Record + query: (args: Record) => Promise +} + +/** + * Fake Prisma client: `$extends` records the extension and returns a client + * whose model delegates route through the extension's `$allOperations` hook + * with a caller-provided base `query` implementation. + */ +export function createFakePrismaClient( + baseQuery: (call: { + model: string + operation: string + args: Record + }) => unknown, +) { + const captured: { + extension?: { + query?: { + $allModels?: { + $allOperations?: (call: QueryHookArgs) => Promise + } + } + } + rawQueries: unknown[] + } = { rawQueries: [] } + + const client = { + $extends(extension: NonNullable) { + captured.extension = extension + return { + __extended: true, + /** Test harness: invoke an operation as Prisma's runtime would. */ + async run( + model: string, + operation: string, + args: Record, + ) { + const hook = captured.extension?.query?.$allModels?.$allOperations + const query = (a: Record) => + Promise.resolve(baseQuery({ model, operation, args: a })) + if (!hook) return query(args) + return hook({ model, operation, args, query }) + }, + } + }, + $queryRaw(query: unknown) { + captured.rawQueries.push(query) + return Promise.resolve( + baseQuery({ model: '$raw', operation: '$queryRaw', args: { query } }), + ) + }, + } + + return { client, captured } +} diff --git a/packages/stack/__tests__/prisma-v3/model-map.test.ts b/packages/stack/__tests__/prisma-v3/model-map.test.ts new file mode 100644 index 00000000..e4d29f5c --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/model-map.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import { + buildModelMap, + buildTableMeta, + reconstructRow, +} from '@/eql/v3/prisma/model-map' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + createdOn: types.TimestampOrd('created_on'), + dob: types.DateEq('dob'), + age: types.IntegerOrd('age'), +}) + +const orders = encryptedTable('orders', { + reference: types.TextEq('reference'), +}) + +describe('buildTableMeta', () => { + const meta = buildTableMeta(users, 'User') + + it('records the model name and table', () => { + expect(meta.modelName).toBe('User') + expect(meta.table).toBe(users) + }) + + it('maps property names to db column names', () => { + expect(meta.propToDb).toEqual({ + email: 'email', + createdOn: 'created_on', + dob: 'dob', + age: 'age', + }) + }) + + it('collects encrypted property names', () => { + expect([...meta.encryptedProps].sort()).toEqual([ + 'age', + 'createdOn', + 'dob', + 'email', + ]) + }) + + it('collects date keys under BOTH property and db names', () => { + // Raw SQL rows are keyed by db name, extension rows by property name — + // Date reconstruction must cover both. + expect([...meta.dateKeys].sort()).toEqual([ + 'createdOn', + 'created_on', + 'dob', + ]) + }) +}) + +describe('reconstructRow', () => { + const meta = buildTableMeta(users, 'User') + + it('rebuilds Date values from strings on property keys', () => { + const row = reconstructRow(meta, { + email: 'a@b.com', + createdOn: '2026-01-02T03:04:05.000Z', + age: 42, + }) + expect(row.createdOn).toBeInstanceOf(Date) + expect((row.createdOn as Date).toISOString()).toBe( + '2026-01-02T03:04:05.000Z', + ) + expect(row.email).toBe('a@b.com') + expect(row.age).toBe(42) + }) + + it('rebuilds Date values on db-name keys (raw SQL rows)', () => { + const row = reconstructRow(meta, { + created_on: '2026-01-02T03:04:05.000Z', + }) + expect(row.created_on).toBeInstanceOf(Date) + }) + + it('leaves null, undefined, and existing Date values alone', () => { + const d = new Date('2026-01-01T00:00:00.000Z') + const row = reconstructRow(meta, { + createdOn: null, + dob: d, + }) + expect(row.createdOn).toBeNull() + expect(row.dob).toBe(d) + }) + + it('does not mutate the input row', () => { + const input = { createdOn: '2026-01-02T03:04:05.000Z' } + reconstructRow(meta, input) + expect(input.createdOn).toBe('2026-01-02T03:04:05.000Z') + }) +}) + +describe('buildModelMap', () => { + it('keys metas by Prisma model name', () => { + const { byModel } = buildModelMap({ User: users, Order: orders }) + expect(byModel.get('User')?.table).toBe(users) + expect(byModel.get('Order')?.table).toBe(orders) + expect(byModel.get('Nope')).toBeUndefined() + }) + + it('maps every column builder back to its table context', () => { + const { byColumn } = buildModelMap({ User: users, Order: orders }) + const ctx = byColumn.get(users.age) + expect(ctx?.tableName).toBe('users') + expect(ctx?.dbName).toBe('age') + expect(ctx?.table).toBe(users) + expect(byColumn.get(orders.reference)?.tableName).toBe('orders') + }) + + it('exposes the built index set for gating', () => { + const { byColumn } = buildModelMap({ User: users }) + expect(byColumn.get(users.email)?.indexes.unique).toBeTruthy() + expect(byColumn.get(users.email)?.indexes.ore).toBeFalsy() + expect(byColumn.get(users.age)?.indexes.ore).toBeTruthy() + }) + + it('rejects the same table registered under two models', () => { + // One builder instance in two metas would make column→table resolution + // ambiguous for the where builders. + expect(() => buildModelMap({ User: users, Person: users })).toThrow( + /registered under more than one model/i, + ) + }) +}) diff --git a/packages/stack/__tests__/prisma-v3/types.test-d.ts b/packages/stack/__tests__/prisma-v3/types.test-d.ts new file mode 100644 index 00000000..dff0b832 --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/types.test-d.ts @@ -0,0 +1,44 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import type { EncryptedWhere, SqlFragment } from '@/eql/v3/prisma' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + age: types.IntegerOrd('age'), + createdOn: types.TimestampOrd('created_on'), + active: types.Boolean('active'), +}) + +declare const where: EncryptedWhere + +describe('where builder plaintext narrowing', () => { + it('pins operand types to the column domain', () => { + expectTypeOf(where.eq(users.email, 'a@b.com')).resolves.toEqualTypeOf() + expectTypeOf(where.gt(users.age, 30)).resolves.toEqualTypeOf() + expectTypeOf( + where.between(users.createdOn, new Date(), new Date()), + ).resolves.toEqualTypeOf() + + // @ts-expect-error — text column takes string, not number + where.eq(users.email, 42) + // @ts-expect-error — integer column takes number, not string + where.gt(users.age, 'thirty') + // @ts-expect-error — timestamp column takes Date + where.lt(users.createdOn, 'yesterday') + // @ts-expect-error — boolean column takes boolean + where.eq(users.active, 'yes') + }) + + it('list operands follow the column type', () => { + expectTypeOf( + where.in(users.email, ['a', 'b']), + ).resolves.toEqualTypeOf() + // @ts-expect-error — numbers in a text column list + where.in(users.email, [1, 2]) + }) + + it('orderBy and null checks are synchronous fragments', () => { + expectTypeOf(where.orderBy(users.age, 'desc')).toEqualTypeOf() + expectTypeOf(where.isNull(users.email)).toEqualTypeOf() + }) +}) diff --git a/packages/stack/__tests__/prisma-v3/where.test.ts b/packages/stack/__tests__/prisma-v3/where.test.ts new file mode 100644 index 00000000..2c4ef884 --- /dev/null +++ b/packages/stack/__tests__/prisma-v3/where.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest' +import { encryptedTable, types } from '@/eql/v3' +import { + EncryptionOperatorError, + PrismaEncryptionError, +} from '@/eql/v3/prisma/errors' +import { buildModelMap } from '@/eql/v3/prisma/model-map' +import { createEncryptedWhere } from '@/eql/v3/prisma/where' +import { + type CapturedSql, + createFailingEncryptionClient, + createMockEncryptionClient, + fakeEnvelope, + fakePrismaNamespace, + renderSql, +} from './mocks' + +const users = encryptedTable('users', { + email: types.TextEq('email'), // equality only + name: types.TextSearch('name'), // equality + order + match + age: types.IntegerOrd('age'), // order/range only (equality via ORE) + bio: types.TextMatch('bio'), // match only + note: types.Text('note'), // storage-only + createdOn: types.TimestampOrd('created_on'), // db name differs from property +}) + +const unregistered = encryptedTable('other', { + field: types.TextEq('field'), +}) + +function setup(encryption = createMockEncryptionClient()) { + const { byColumn } = buildModelMap({ User: users }) + const where = createEncryptedWhere({ + encryptionClient: encryption.client, + prisma: fakePrismaNamespace, + byColumn, + }) + return { where, calls: encryption.calls } +} + +const envelopeJson = (value: unknown, column: string) => + JSON.stringify(fakeEnvelope(value, column)) + +describe('equality operators', () => { + it('eq lowers to the two-arg eql_v3.eq with a full-envelope jsonb operand', async () => { + const { where } = setup() + const frag = (await where.eq(users.email, 'a@b.com')) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.eq("email", $1::jsonb)') + expect(frag.values).toEqual([envelopeJson('a@b.com', 'email')]) + }) + + it('ne lowers to eql_v3.neq', async () => { + const { where } = setup() + const frag = (await where.ne(users.email, 'a@b.com')) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.neq("email", $1::jsonb)') + }) + + it('eq on an order-only column is allowed (equality via ORE)', async () => { + const { where } = setup() + const frag = (await where.eq(users.age, 42)) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.eq("age", $1::jsonb)') + }) + + it('eq on a match-only column throws with context', async () => { + const { where } = setup() + await expect(where.eq(users.bio, 'x')).rejects.toThrow( + EncryptionOperatorError, + ) + await expect(where.eq(users.bio, 'x')).rejects.toThrow(/bio/) + }) + + it('eq on a storage-only column throws', async () => { + const { where } = setup() + await expect(where.eq(users.note, 'x')).rejects.toThrow( + EncryptionOperatorError, + ) + }) + + it('a column from an unregistered table throws', async () => { + const { where } = setup() + await expect(where.eq(unregistered.field, 'x')).rejects.toThrow( + /not registered/i, + ) + }) + + it('a null operand throws and points at isNull', async () => { + const { where } = setup() + await expect( + where.eq(users.email, null as unknown as string), + ).rejects.toThrow(/isNull/) + }) +}) + +describe('comparison operators', () => { + it.each([ + ['gt', 'eql_v3.gt'], + ['gte', 'eql_v3.gte'], + ['lt', 'eql_v3.lt'], + ['lte', 'eql_v3.lte'], + ] as const)('%s lowers to %s', async (op, fn) => { + const { where } = setup() + const frag = (await where[op](users.age, 30)) as CapturedSql + expect(renderSql(frag)).toBe(`${fn}("age", $1::jsonb)`) + expect(frag.values).toEqual([envelopeJson(30, 'age')]) + }) + + it('comparison on a column without an ore index throws', async () => { + const { where } = setup() + await expect(where.gt(users.email, 'x')).rejects.toThrow( + EncryptionOperatorError, + ) + }) + + it('between composes gte AND lte with two operands', async () => { + const { where } = setup() + const frag = (await where.between(users.age, 10, 90)) as CapturedSql + expect(renderSql(frag)).toBe( + '(eql_v3.gte("age", $1::jsonb) AND eql_v3.lte("age", $2::jsonb))', + ) + expect(frag.values).toEqual([ + envelopeJson(10, 'age'), + envelopeJson(90, 'age'), + ]) + }) + + it('notBetween wraps the range in NOT', async () => { + const { where } = setup() + const frag = (await where.notBetween(users.age, 10, 90)) as CapturedSql + expect(renderSql(frag)).toBe( + 'NOT (eql_v3.gte("age", $1::jsonb) AND eql_v3.lte("age", $2::jsonb))', + ) + }) +}) + +describe('free-text match', () => { + it('contains lowers to eql_v3.contains', async () => { + const { where } = setup() + const frag = (await where.contains(users.name, 'ada')) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.contains("name", $1::jsonb)') + }) + + it('contains on a column without a match index throws', async () => { + const { where } = setup() + await expect(where.contains(users.email, 'x')).rejects.toThrow( + EncryptionOperatorError, + ) + }) +}) + +describe('list operators', () => { + it('in joins equality fragments with OR', async () => { + const { where } = setup() + const frag = (await where.in(users.email, ['a', 'b'])) as CapturedSql + expect(renderSql(frag)).toBe( + '(eql_v3.eq("email", $1::jsonb) OR eql_v3.eq("email", $2::jsonb))', + ) + expect(frag.values).toEqual([ + envelopeJson('a', 'email'), + envelopeJson('b', 'email'), + ]) + }) + + it('notIn joins inequality fragments with AND', async () => { + const { where } = setup() + const frag = (await where.notIn(users.email, ['a', 'b'])) as CapturedSql + expect(renderSql(frag)).toBe( + '(eql_v3.neq("email", $1::jsonb) AND eql_v3.neq("email", $2::jsonb))', + ) + }) + + it('preserves operand order for long lists (bounded concurrency)', async () => { + const { where } = setup() + const values = Array.from({ length: 10 }, (_, i) => `v${i}`) + const frag = (await where.in(users.email, values)) as CapturedSql + expect(frag.values).toEqual(values.map((v) => envelopeJson(v, 'email'))) + }) + + it('an empty list throws', async () => { + const { where } = setup() + await expect(where.in(users.email, [])).rejects.toThrow(/empty/i) + }) +}) + +describe('ordering and null checks', () => { + it('orderBy emits ord_term with the default ASC direction', () => { + const { where } = setup() + const frag = where.orderBy(users.age) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.ord_term("age") ASC') + expect(frag.values).toEqual([]) + }) + + it('orderBy desc', () => { + const { where } = setup() + const frag = where.orderBy(users.age, 'desc') as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.ord_term("age") DESC') + }) + + it('orderBy on a column without an ore index throws', () => { + const { where } = setup() + expect(() => where.orderBy(users.email)).toThrow(EncryptionOperatorError) + }) + + it('isNull / isNotNull work on any column, including storage-only', () => { + const { where } = setup() + expect(renderSql(where.isNull(users.note) as CapturedSql)).toBe( + '"note" IS NULL', + ) + expect(renderSql(where.isNotNull(users.email) as CapturedSql)).toBe( + '"email" IS NOT NULL', + ) + }) +}) + +describe('identifiers and mapping', () => { + it('uses the db column name, not the JS property name', async () => { + const { where } = setup() + const frag = where.orderBy(users.createdOn) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.ord_term("created_on") ASC') + }) + + it('escapes double quotes in column identifiers', async () => { + const weird = encryptedTable('weird', { + col: types.TextEq('we"ird'), + }) + const { byColumn } = buildModelMap({ Weird: weird }) + const where = createEncryptedWhere({ + encryptionClient: createMockEncryptionClient().client, + prisma: fakePrismaNamespace, + byColumn, + }) + const frag = (await where.eq(weird.col, 'x')) as CapturedSql + expect(renderSql(frag)).toBe('eql_v3.eq("we""ird", $1::jsonb)') + }) +}) + +describe('operation plumbing', () => { + it('passes lockContext and audit through to the encrypt operation', async () => { + const encryption = createMockEncryptionClient() + const { where, calls } = setup(encryption) + const lockContext = { kind: 'lc' } + const audit = { metadata: { actor: 'test' } } + await where.eq(users.email, 'a@b.com', { lockContext, audit }) + expect(calls.lockContexts).toEqual([lockContext]) + expect(calls.audits).toEqual([audit]) + }) + + it('wraps encryption failures in PrismaEncryptionError', async () => { + const { byColumn } = buildModelMap({ User: users }) + const where = createEncryptedWhere({ + encryptionClient: createFailingEncryptionClient('nope'), + prisma: fakePrismaNamespace, + byColumn, + }) + await expect(where.eq(users.email, 'x')).rejects.toThrow( + PrismaEncryptionError, + ) + }) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index c4bcacc6..51e08278 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -135,6 +135,16 @@ "default": "./dist/eql/v3/index.cjs" } }, + "./eql/v3/prisma": { + "import": { + "types": "./dist/eql/v3/prisma/index.d.ts", + "default": "./dist/eql/v3/prisma/index.js" + }, + "require": { + "types": "./dist/eql/v3/prisma/index.d.cts", + "default": "./dist/eql/v3/prisma/index.cjs" + } + }, "./v3": { "import": { "types": "./dist/encryption/v3.d.ts", diff --git a/packages/stack/src/eql/v3/prisma/errors.ts b/packages/stack/src/eql/v3/prisma/errors.ts new file mode 100644 index 00000000..2af651e8 --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/errors.ts @@ -0,0 +1,58 @@ +import type { EncryptionError } from '@/errors' + +/** + * A v3 where/order builder was used against a column that cannot support it — + * wrong capability for the operator, a storage-only column, a column from an + * unregistered table, or an invalid operand. + * + * Thrown instead of falling back to a plain comparison: a v3 domain column + * has no plaintext form, and mis-lowered SQL either fails the domain CHECK at + * runtime or (worse) silently matches nothing. Same convention as the Drizzle + * v3 operators. + */ +export class EncryptionOperatorError extends Error { + constructor( + message: string, + public readonly context?: { + columnName?: string + tableName?: string + operator?: string + }, + ) { + super(message) + this.name = 'EncryptionOperatorError' + } +} + +/** + * An encrypted column was referenced through Prisma's TYPED query surface + * (`where`, `orderBy`, `distinct`, `cursor`, `having`). Prisma lowers Json + * comparisons with a column-side `::jsonb` cast that bypasses the `eql_v3` + * domain operators, so a typed filter on an encrypted column silently returns + * zero rows — the guard turns that into a loud error pointing at the + * fragment builders. + */ +export class PrismaEncryptedColumnError extends Error { + constructor( + message: string, + public readonly context?: { + model?: string + field?: string + clause?: string + }, + ) { + super(message) + this.name = 'PrismaEncryptedColumnError' + } +} + +/** An encryption/decryption operation failed while servicing a Prisma call. */ +export class PrismaEncryptionError extends Error { + constructor( + message: string, + public readonly encryptionError?: EncryptionError, + ) { + super(message) + this.name = 'PrismaEncryptionError' + } +} diff --git a/packages/stack/src/eql/v3/prisma/extension.ts b/packages/stack/src/eql/v3/prisma/extension.ts new file mode 100644 index 00000000..a6eba234 --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/extension.ts @@ -0,0 +1,289 @@ +import type { EncryptionClient } from '@/encryption' +import type { AuditConfig } from '@/encryption/operations/base-operation' +import type { LockContext } from '@/identity' +import { PrismaEncryptedColumnError, PrismaEncryptionError } from './errors' +import { type ModelTableMeta, reconstructRow } from './model-map' +import type { PrismaNamespaceLike } from './types' + +/** Operations whose `args` carry writable model data. */ +const DATA_ARGS: Record> = { + create: ['data'], + update: ['data'], + updateMany: ['data'], + updateManyAndReturn: ['data'], + createMany: ['data'], + createManyAndReturn: ['data'], + upsert: ['create', 'update'], +} + +/** Operations whose result is a row / array of rows to decrypt. */ +const ROW_RESULT_OPS = new Set([ + 'create', + 'update', + 'upsert', + 'delete', + 'findMany', + 'findFirst', + 'findUnique', + 'findFirstOrThrow', + 'findUniqueOrThrow', + 'createManyAndReturn', + 'updateManyAndReturn', +]) + +/** Clauses of Prisma's TYPED query surface an encrypted column must not enter. */ +const GUARDED_CLAUSES = ['where', 'orderBy', 'distinct', 'cursor', 'having'] + +const LOGICAL_KEYS = new Set(['AND', 'OR', 'NOT']) + +type QueryHookCall = { + model: string + operation: string + args: Record + query: (args: Record) => Promise +} + +type ChainableOperation = { + withLockContext(lockContext: LockContext): ChainableOperation + audit(config: AuditConfig): ChainableOperation + then: PromiseLike<{ + data?: unknown + failure?: { message: string } + }>['then'] +} + +export type ExtensionDeps = { + encryptionClient: EncryptionClient + prisma: PrismaNamespaceLike + byModel: Map + lockContext?: LockContext + audit?: AuditConfig +} + +/** + * Build the Prisma client-extension object (the argument to `$extends`) that + * makes registered models transparently encrypt on write and decrypt on read. + * + * What it deliberately does NOT do: service encrypted filters. Prisma lowers + * Json comparisons with a column-side `::jsonb` cast that bypasses the + * `eql_v3` domain operators (silent zero rows), and range filters on Json + * don't exist — so any encrypted column referenced through the typed `where` + * / `orderBy` / `distinct` / `cursor` / `having` surface throws + * {@link PrismaEncryptedColumnError}. Encrypted search goes through the + * `where` fragment builders + `$queryRawEncrypted`. + * + * v1 limits, by design: + * - Nested writes/reads (relation `create`/`include`) are passed through + * untouched — only the intercepted model's own fields are processed. + * - Field-update operator objects (`{ set: … }`) are not unwrapped; write + * plain values to encrypted fields. + */ +export function createEncryptedExtension(deps: ExtensionDeps): unknown { + const { encryptionClient, prisma, byModel, lockContext, audit } = deps + + function runOperation(op: ChainableOperation) { + const withLock = lockContext ? op.withLockContext(lockContext) : op + if (audit) withLock.audit(audit) + return withLock + } + + async function unwrap(op: ChainableOperation, what: string): Promise { + const result = await runOperation(op) + if (result.failure) { + throw new PrismaEncryptionError( + `[prisma v3]: ${what} failed: ${result.failure.message}`, + result.failure as never, + ) + } + return result.data as T + } + + // ------------------------------------------------------------------------- + // Typed-query guard + // ------------------------------------------------------------------------- + + function throwGuard( + meta: ModelTableMeta, + field: string, + clause: string, + ): never { + throw new PrismaEncryptedColumnError( + `[prisma v3]: encrypted column "${field}" of model "${meta.modelName}" cannot be used in a typed \`${clause}\` — Prisma's Json lowering bypasses the eql_v3 operators (filters would silently match nothing). Use the encrypted where/orderBy fragment builders with $queryRawEncrypted instead.`, + { model: meta.modelName, field, clause }, + ) + } + + function guardWhere( + meta: ModelTableMeta, + where: unknown, + clause: string, + ): void { + if (where == null || typeof where !== 'object') return + for (const entry of Array.isArray(where) ? where : [where]) { + if (entry == null || typeof entry !== 'object') continue + for (const [key, value] of Object.entries(entry)) { + if (LOGICAL_KEYS.has(key)) { + guardWhere(meta, value, clause) + } else if (meta.encryptedProps.has(key)) { + throwGuard(meta, key, clause) + } + // Any other object value is a relation filter — a DIFFERENT model's + // fields — so it is deliberately not recursed. + } + } + } + + function guardKeyed( + meta: ModelTableMeta, + value: unknown, + clause: string, + ): void { + if (value == null) return + if (typeof value === 'string') { + if (meta.encryptedProps.has(value)) throwGuard(meta, value, clause) + return + } + for (const entry of Array.isArray(value) ? value : [value]) { + if (typeof entry === 'string') { + if (meta.encryptedProps.has(entry)) throwGuard(meta, entry, clause) + } else if (entry != null && typeof entry === 'object') { + for (const key of Object.keys(entry)) { + if (meta.encryptedProps.has(key)) throwGuard(meta, key, clause) + } + } + } + } + + function guardArgs( + meta: ModelTableMeta, + args: Record, + ): void { + for (const clause of GUARDED_CLAUSES) { + if (!(clause in args)) continue + const value = args[clause] + if (clause === 'where' || clause === 'having') { + guardWhere(meta, value, clause) + } else { + guardKeyed(meta, value, clause) + } + } + } + + // ------------------------------------------------------------------------- + // Write path + // ------------------------------------------------------------------------- + + /** + * `null` on an encrypted field must reach the database as SQL NULL. Prisma + * writes a plain `null` on a Json field as JSON `null`, which fails the + * eql_v3 domain CHECK — so it is rewritten to `Prisma.DbNull`. `undefined` + * is left alone: in Prisma it means "field not provided". + */ + function normalizeNulls( + meta: ModelTableMeta, + input: Record, + row: Record, + ): Record { + const out = { ...row } + for (const prop of meta.encryptedProps) { + if (!(prop in out)) continue + if (input[prop] === undefined) { + // "Field not provided" must survive encryption untouched — encrypt + // helpers may collapse undefined to null, which would otherwise be + // promoted to DbNull below and null out the column on update. + out[prop] = undefined + } else if (out[prop] === null) { + out[prop] = prisma.DbNull + } + } + return out + } + + async function encryptData( + meta: ModelTableMeta, + data: unknown, + ): Promise { + if (Array.isArray(data)) { + const encrypted = await unwrap[]>( + encryptionClient.bulkEncryptModels( + data as never, + meta.table as never, + ) as unknown as ChainableOperation, + 'encrypting model data', + ) + return encrypted.map((row, i) => + normalizeNulls(meta, data[i] as Record, row), + ) + } + if (data == null || typeof data !== 'object') return data + const encrypted = await unwrap>( + encryptionClient.encryptModel( + data as never, + meta.table as never, + ) as unknown as ChainableOperation, + 'encrypting model data', + ) + return normalizeNulls(meta, data as Record, encrypted) + } + + // ------------------------------------------------------------------------- + // Read path + // ------------------------------------------------------------------------- + + async function decryptResult( + meta: ModelTableMeta, + result: unknown, + ): Promise { + if (result == null) return result + if (Array.isArray(result)) { + const rows = await unwrap[]>( + encryptionClient.bulkDecryptModels( + result as never, + ) as unknown as ChainableOperation, + 'decrypting result rows', + ) + return rows.map((row) => reconstructRow(meta, row)) + } + if (typeof result !== 'object') return result + const row = await unwrap>( + encryptionClient.decryptModel( + result as never, + ) as unknown as ChainableOperation, + 'decrypting result row', + ) + return reconstructRow(meta, row) + } + + // ------------------------------------------------------------------------- + // The hook + // ------------------------------------------------------------------------- + + return { + name: 'cipherstash-eql-v3', + query: { + $allModels: { + async $allOperations({ model, operation, args, query }: QueryHookCall) { + const meta = byModel.get(model) + if (!meta) return query(args) + + guardArgs(meta, args) + + let nextArgs = args + const dataKeys = DATA_ARGS[operation] + if (dataKeys) { + nextArgs = { ...args } + for (const key of dataKeys) { + if (key in nextArgs) { + nextArgs[key] = await encryptData(meta, nextArgs[key]) + } + } + } + + const result = await query(nextArgs) + if (!ROW_RESULT_OPS.has(operation)) return result + return decryptResult(meta, result) + }, + }, + }, + } +} diff --git a/packages/stack/src/eql/v3/prisma/index.ts b/packages/stack/src/eql/v3/prisma/index.ts new file mode 100644 index 00000000..9d8d4616 --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/index.ts @@ -0,0 +1,144 @@ +import type { AnyV3Table } from '@/eql/v3' +import { PrismaEncryptionError } from './errors' +import { createEncryptedExtension } from './extension' +import { + buildModelMap, + buildTableMeta, + type ModelTableMeta, + reconstructRow, +} from './model-map' +import type { + EncryptedCallOpts, + EncryptedPrismaConfig, + PrismaClientLike, + SqlFragment, +} from './types' +import { createEncryptedWhere, type EncryptedWhere } from './where' + +export interface EncryptedPrismaInstance { + /** + * The `$extends`-wrapped Prisma client: registered models transparently + * encrypt on write and decrypt on read, and any encrypted column reaching + * the typed `where`/`orderBy`/`distinct`/`cursor`/`having` surface throws + * (Prisma's Json lowering bypasses the `eql_v3` operators — a typed filter + * would silently match nothing). + */ + client: Client + /** Capability-checked `Prisma.sql` fragment builders for encrypted search. */ + where: EncryptedWhere + /** + * Run a raw query and decrypt the result rows against `table` (registered + * or not), including `Date` reconstruction under the DB column names. + */ + $queryRawEncrypted< + T extends Record = Record, + >( + table: AnyV3Table, + query: SqlFragment, + opts?: EncryptedCallOpts, + ): Promise +} + +/** + * Create the encrypted Prisma wrapper for **EQL v3** schemas — tables + * authored with `@cipherstash/stack/eql/v3` whose columns are native + * `eql_v3.*` domains, declared as `Json` fields in `schema.prisma`. + * + * @example + * ```typescript + * import { PrismaClient, Prisma } from './generated/client' + * import { EncryptionV3, encryptedTable, types } from '@cipherstash/stack/v3' + * import { encryptedPrisma } from '@cipherstash/stack/eql/v3/prisma' + * + * const users = encryptedTable('users', { + * email: types.TextEq('email'), + * age: types.IntegerOrd('age'), + * }) + * + * const encryption = await EncryptionV3({ schemas: [users] }) + * const { client, where, $queryRawEncrypted } = encryptedPrisma({ + * encryptionClient: encryption, + * prismaClient: new PrismaClient({ adapter }), + * prisma: Prisma, + * tables: { User: users }, + * }) + * + * // CRUD — transparent encrypt/decrypt + * await client.user.create({ data: { email: 'a@b.com', age: 30 } }) + * + * // Encrypted search — fragment builders + $queryRawEncrypted + * const rows = await $queryRawEncrypted( + * users, + * Prisma.sql`SELECT * FROM users WHERE ${await where.eq(users.email, 'a@b.com')}`, + * ) + * ``` + */ +export function encryptedPrisma( + config: EncryptedPrismaConfig, +): EncryptedPrismaInstance { + const { encryptionClient, prismaClient, prisma, tables } = config + const { byModel, byColumn } = buildModelMap(tables) + + const extension = createEncryptedExtension({ + encryptionClient, + prisma, + byModel, + lockContext: config.lockContext, + audit: config.audit, + }) + const client = prismaClient.$extends(extension) as Client + + const where = createEncryptedWhere({ encryptionClient, prisma, byColumn }) + + const metaByTable = new Map() + for (const meta of byModel.values()) metaByTable.set(meta.table, meta) + + async function $queryRawEncrypted< + T extends Record = Record, + >( + table: AnyV3Table, + query: SqlFragment, + opts?: EncryptedCallOpts, + ): Promise { + const rows = await prismaClient.$queryRaw(query) + if (!Array.isArray(rows) || rows.length === 0) return (rows ?? []) as T[] + + const meta = metaByTable.get(table) ?? buildTableMeta(table) + + const baseOp = encryptionClient.bulkDecryptModels(rows as never) + const lockContext = opts?.lockContext ?? config.lockContext + const audit = opts?.audit ?? config.audit + const op = lockContext ? baseOp.withLockContext(lockContext) : baseOp + if (audit) op.audit(audit) + + const result = await op + if (result.failure) { + throw new PrismaEncryptionError( + `[prisma v3]: failed to decrypt raw query rows: ${result.failure.message}`, + result.failure, + ) + } + return (result.data as Record[]).map((row) => + reconstructRow(meta, row), + ) as T[] + } + + return { client, where, $queryRawEncrypted } +} + +export { + EncryptionOperatorError, + PrismaEncryptedColumnError, + PrismaEncryptionError, +} from './errors' +export { createEncryptedExtension } from './extension' +export type { ModelTableMeta } from './model-map' +export type { + EncryptedCallOpts, + EncryptedPrismaConfig, + PrismaClientLike, + PrismaNamespaceLike, + SqlFragment, +} from './types' +export type { EncryptedWhere } from './where' +export { createEncryptedWhere } from './where' diff --git a/packages/stack/src/eql/v3/prisma/model-map.ts b/packages/stack/src/eql/v3/prisma/model-map.ts new file mode 100644 index 00000000..d32500df --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/model-map.ts @@ -0,0 +1,120 @@ +import type { AnyEncryptedV3Column, AnyV3Table } from '@/eql/v3' +import type { ColumnSchema } from '@/schema' + +/** + * Everything the extension and raw-decrypt paths need to know about one + * registered table, derived once at factory time. + */ +export type ModelTableMeta = { + /** The Prisma model name this table is registered under (if any). */ + modelName?: string + table: AnyV3Table + /** JS property name → DB column name, for every encrypted column. */ + propToDb: Record + /** Encrypted JS property names (the keys Prisma models use). */ + encryptedProps: ReadonlySet + /** + * Row keys whose values must be rebuilt into `Date` — the `cast_as: + * 'date' | 'timestamp'` columns, under BOTH the property name (extension + * rows) and the DB column name (raw SQL rows). + */ + dateKeys: ReadonlySet +} + +/** Per-column context for the where/order fragment builders. */ +export type ColumnContext = { + table: AnyV3Table + tableName: string + builder: AnyEncryptedV3Column + dbName: string + /** The built index set — the authoritative capability source for gating. */ + indexes: ColumnSchema['indexes'] +} + +/** Derive a {@link ModelTableMeta} for one table. */ +export function buildTableMeta( + table: AnyV3Table, + modelName?: string, +): ModelTableMeta { + const propToDb = table.buildColumnKeyMap() + const { columns } = table.build() + + const dateKeys = new Set() + for (const [property, dbName] of Object.entries(propToDb)) { + const castAs = columns[dbName]?.cast_as + // Both 'date' and 'timestamp' columns decrypt to a JS `Date`. + if (castAs !== 'date' && castAs !== 'timestamp') continue + dateKeys.add(property) + dateKeys.add(dbName) + } + + return { + modelName, + table, + propToDb, + encryptedProps: new Set(Object.keys(propToDb)), + dateKeys, + } +} + +/** + * Build the two lookup maps the integration runs on: + * - `byModel`: Prisma model name → table meta (extension interception) + * - `byColumn`: column builder instance → column context (fragment builders) + * + * A table registered under two model names would make the column→table + * resolution ambiguous, so it throws. + */ +export function buildModelMap(tables: Record): { + byModel: Map + byColumn: Map +} { + const byModel = new Map() + const byColumn = new Map() + const seenTables = new Map() + + for (const [modelName, table] of Object.entries(tables)) { + const priorModel = seenTables.get(table) + if (priorModel !== undefined) { + throw new Error( + `[prisma v3]: table "${table.tableName}" is registered under more than one model ("${priorModel}" and "${modelName}") — column lookups would be ambiguous`, + ) + } + seenTables.set(table, modelName) + + const meta = buildTableMeta(table, modelName) + byModel.set(modelName, meta) + + for (const builder of Object.values(table.columnBuilders)) { + byColumn.set(builder, { + table, + tableName: table.tableName, + builder: builder as AnyEncryptedV3Column, + dbName: builder.getName(), + indexes: builder.build().indexes, + }) + } + } + + return { byModel, byColumn } +} + +/** + * Rebuild `Date` values on a decrypted row, covering both property-keyed + * (extension) and db-name-keyed (raw SQL) rows. Non-mutating; idempotent if a + * value is already a `Date`. + */ +export function reconstructRow( + meta: ModelTableMeta, + row: Record, +): Record { + const out: Record = { ...row } + for (const key of meta.dateKeys) { + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out +} diff --git a/packages/stack/src/eql/v3/prisma/sql-dialect.ts b/packages/stack/src/eql/v3/prisma/sql-dialect.ts new file mode 100644 index 00000000..ed83da0c --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/sql-dialect.ts @@ -0,0 +1,89 @@ +/** + * EQL v3 SQL emission for the Prisma integration. + * + * Lowering targets the CURRENT vendored bundle (protect-ffi 0.27 line): + * the public two-arg function forms — `eql_v3.eq(col, $::jsonb)`, + * `eql_v3.contains(col, $::jsonb)`, … — which coerce the jsonb operand into + * the column's domain and compare the right index term for that domain + * (including equality-via-ORE on order-only domains). Because the operand is + * coerced into the domain, it MUST be a full storage envelope that passes the + * domain CHECK; see `encryptOperand` in where.ts (the single swap point, + * CIP-3402/CIP-3423). This mirrors the Drizzle v3 dialect's SHAPE, not its + * SQL — that branch targets a different bundle generation with public + * term-only constructors. + * + * Fragments are assembled with the CALLER-INJECTED `Prisma.sql` (from the + * user's generated client) so the values travel as bound parameters and the + * result composes into `$queryRaw` untouched. Identifiers are always + * double-quoted with embedded quotes doubled. + */ + +/** The `Prisma.sql` tag, callable with a plain strings array. */ +export type SqlTag = ( + strings: ReadonlyArray, + ...values: unknown[] +) => unknown + +export type BinaryFn = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' + +/** Quote a SQL identifier, doubling embedded double quotes. */ +export function quoteIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"` +} + +export const v3PrismaDialect = { + /** `eql_v3.(, $1::jsonb)` */ + binary(sql: SqlTag, fn: BinaryFn, ident: string, operand: string): unknown { + return sql([`eql_v3.${fn}(${ident}, `, '::jsonb)'], operand) + }, + + /** `(eql_v3.gte(, $1::jsonb) AND eql_v3.lte(, $2::jsonb))` */ + between( + sql: SqlTag, + ident: string, + min: string, + max: string, + negate: boolean, + ): unknown { + const prefix = negate ? 'NOT (' : '(' + return sql( + [ + `${prefix}eql_v3.gte(${ident}, `, + `::jsonb) AND eql_v3.lte(${ident}, `, + '::jsonb))', + ], + min, + max, + ) + }, + + /** + * `(eql_v3.eq(, $1::jsonb) OR eql_v3.eq(, $2::jsonb) …)` — + * `fn`/`joiner` are `eq`/`OR` for IN, `neq`/`AND` for NOT IN. + */ + list( + sql: SqlTag, + fn: 'eq' | 'neq', + joiner: 'OR' | 'AND', + ident: string, + operands: string[], + ): unknown { + const call = `eql_v3.${fn}(${ident}, ` + const strings = [ + `(${call}`, + ...operands.slice(1).map(() => `::jsonb) ${joiner} ${call}`), + '::jsonb))', + ] + return sql(strings, ...operands) + }, + + /** `eql_v3.ord_term() ASC|DESC` — for interpolation after ORDER BY. */ + ordTerm(sql: SqlTag, ident: string, direction: 'ASC' | 'DESC'): unknown { + return sql([`eql_v3.ord_term(${ident}) ${direction}`]) + }, + + /** ` IS [NOT] NULL` */ + nullCheck(sql: SqlTag, ident: string, negate: boolean): unknown { + return sql([`${ident} IS ${negate ? 'NOT ' : ''}NULL`]) + }, +} diff --git a/packages/stack/src/eql/v3/prisma/types.ts b/packages/stack/src/eql/v3/prisma/types.ts new file mode 100644 index 00000000..e62ddacf --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/types.ts @@ -0,0 +1,49 @@ +import type { EncryptionClient } from '@/encryption' +import type { AuditConfig } from '@/encryption/operations/base-operation' +import type { AnyV3Table } from '@/eql/v3' +import type { LockContext } from '@/identity' + +/** + * The slice of the generated client's `Prisma` namespace the integration + * needs, injected by the caller. It cannot be imported here: `Prisma.sql` + * and `Prisma.DbNull` are exported by the USER'S generated client (Prisma 7 + * generates to a caller-chosen path), not by a package this library could + * resolve. + */ +export interface PrismaNamespaceLike { + /** `Prisma.sql` — callable with a plain strings array + values. */ + sql: (strings: ReadonlyArray, ...values: unknown[]) => unknown + /** `Prisma.DbNull` — writes SQL NULL (vs JSON `null`) to a Json column. */ + DbNull: unknown +} + +/** + * Structural view of a PrismaClient instance — no dependency on + * `@prisma/client` (mirrors `SupabaseClientLike`). + */ +export interface PrismaClientLike { + $extends(extension: unknown): unknown + $queryRaw(query: unknown, ...values: unknown[]): Promise +} + +/** An opaque `Prisma.sql` fragment (the return of the injected tag). */ +export type SqlFragment = unknown + +/** Per-call encryption options, mirroring the Supabase builder's surface. */ +export type EncryptedCallOpts = { + lockContext?: LockContext + audit?: AuditConfig +} + +export type EncryptedPrismaConfig = { + encryptionClient: EncryptionClient + prismaClient: Client + /** The `Prisma` namespace from the caller's generated client. */ + prisma: PrismaNamespaceLike + /** Prisma model name → v3 table schema, e.g. `{ User: users }`. */ + tables: Record + /** Applied to every extension encrypt/decrypt operation. */ + lockContext?: LockContext + /** Applied to every extension encrypt/decrypt operation. */ + audit?: AuditConfig +} diff --git a/packages/stack/src/eql/v3/prisma/where.ts b/packages/stack/src/eql/v3/prisma/where.ts new file mode 100644 index 00000000..b15c6955 --- /dev/null +++ b/packages/stack/src/eql/v3/prisma/where.ts @@ -0,0 +1,298 @@ +import type { EncryptionClient } from '@/encryption' +import type { AnyEncryptedV3Column, PlaintextForColumn } from '@/eql/v3' +import { EncryptionOperatorError, PrismaEncryptionError } from './errors' +import type { ColumnContext } from './model-map' +import { type BinaryFn, quoteIdent, v3PrismaDialect } from './sql-dialect' +import type { + EncryptedCallOpts, + PrismaNamespaceLike, + SqlFragment, +} from './types' + +/** Operand-encryption concurrency cap for list operators. */ +const MAX_LIST_CONCURRENCY = 4 + +/** + * Capability-checked `Prisma.sql` fragment builders for encrypted columns. + * Compose the fragments into `$queryRaw` (or `$queryRawEncrypted`): + * + * ```ts + * const rows = await $queryRawEncrypted( + * users, + * Prisma.sql`SELECT * FROM users WHERE ${await where.eq(users.email, 'a@b.com')} AND plan = ${plan}`, + * ) + * ``` + */ +export interface EncryptedWhere { + eq( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + ne( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + gt( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + gte( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + lt( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + lte( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + between( + column: Col, + min: PlaintextForColumn, + max: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + notBetween( + column: Col, + min: PlaintextForColumn, + max: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + /** + * Free-text (bloom-filter) containment — TOKEN matching, not SQL `LIKE`: + * the value is tokenized/downcased the same way the stored value was; + * wildcards are not interpreted. + */ + contains( + column: Col, + value: PlaintextForColumn, + opts?: EncryptedCallOpts, + ): Promise + in( + column: Col, + values: ReadonlyArray>, + opts?: EncryptedCallOpts, + ): Promise + notIn( + column: Col, + values: ReadonlyArray>, + opts?: EncryptedCallOpts, + ): Promise + /** `eql_v3.ord_term(col) ASC|DESC` — interpolate after `ORDER BY`. */ + orderBy(column: AnyEncryptedV3Column, direction?: 'asc' | 'desc'): SqlFragment + isNull(column: AnyEncryptedV3Column): SqlFragment + isNotNull(column: AnyEncryptedV3Column): SqlFragment +} + +type Gate = 'equality' | 'orderAndRange' | 'freeTextSearch' + +async function mapWithConcurrency( + values: ReadonlyArray, + limit: number, + mapper: (value: T) => Promise, +): Promise { + const results = new Array(values.length) + let next = 0 + async function worker(): Promise { + while (next < values.length) { + const index = next + next += 1 + results[index] = await mapper(values[index]) + } + } + await Promise.all( + Array.from({ length: Math.min(limit, values.length) }, () => worker()), + ) + return results +} + +export function createEncryptedWhere(deps: { + encryptionClient: EncryptionClient + prisma: PrismaNamespaceLike + byColumn: Map +}): EncryptedWhere { + const { encryptionClient, prisma, byColumn } = deps + const sql = prisma.sql + + function resolve(column: AnyEncryptedV3Column, operator: string) { + const ctx = byColumn.get(column) + if (!ctx) { + throw new EncryptionOperatorError( + `[prisma v3]: column "${column?.getName?.() ?? 'unknown'}" is not registered — pass its table in the \`tables\` map of encryptedPrisma()`, + { columnName: column?.getName?.(), operator }, + ) + } + return { ctx, ident: quoteIdent(ctx.dbName) } + } + + /** + * Gate an operator against the column's built index set — the same + * authoritative source the encrypt config is built from. Client-side + * gating is load-bearing: the bundle defines "unsupported" stub functions + * for missing capabilities, so without this the failure would surface as + * an opaque SQL error (or worse, an empty result). + */ + function gate(ctx: ColumnContext, operator: string, need: Gate): void { + const { indexes } = ctx + const ok = + need === 'equality' + ? Boolean(indexes.unique || indexes.ore) + : need === 'orderAndRange' + ? Boolean(indexes.ore) + : Boolean(indexes.match) + if (!ok) { + throw new EncryptionOperatorError( + `[prisma v3]: operator "${operator}" requires ${need} on column "${ctx.dbName}" (${ctx.builder.getEqlType()} does not support it) — declare the column with a domain that carries that capability`, + { + columnName: ctx.dbName, + tableName: ctx.tableName, + operator, + }, + ) + } + } + + /** + * Encrypt one filter operand as a FULL storage envelope, serialized to + * jsonb text. + * + * INTERIM + the single swap point (CIP-3402 / CIP-3423): the two-arg + * `eql_v3.*` functions coerce their jsonb operand into the column's domain, + * whose CHECK requires the storage keys — so a term-only operand is + * impossible until protect-ffi ships v3 scalar query terms AND the bundle + * gives them a public SQL entry point. When that lands, swap the encryption + * call here and move the dialect off the domain-coercing forms in the SAME + * change. Every consumer treats the returned operand as an opaque, + * already-encoded string. + */ + async function encryptOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + opts?: EncryptedCallOpts, + ): Promise { + if (value == null) { + throw new EncryptionOperatorError( + `[prisma v3]: cannot encrypt a null operand for column "${ctx.dbName}" — use isNull('${ctx.dbName}') for NULL checks`, + { columnName: ctx.dbName, tableName: ctx.tableName, operator }, + ) + } + + const baseOp = encryptionClient.encrypt(value as never, { + column: ctx.builder as never, + table: ctx.table as never, + }) + const op = opts?.lockContext + ? baseOp.withLockContext(opts.lockContext) + : baseOp + if (opts?.audit) op.audit(opts.audit) + + const result = await op + if (result.failure) { + throw new PrismaEncryptionError( + `[prisma v3]: failed to encrypt operand for column "${ctx.dbName}": ${result.failure.message}`, + result.failure, + ) + } + return JSON.stringify(result.data) + } + + async function binary( + fn: BinaryFn, + need: Gate, + column: AnyEncryptedV3Column, + value: unknown, + opts?: EncryptedCallOpts, + ): Promise { + const { ctx, ident } = resolve(column, fn) + gate(ctx, fn, need) + const operand = await encryptOperand(ctx, value, fn, opts) + return v3PrismaDialect.binary(sql, fn, ident, operand) + } + + async function range( + negate: boolean, + column: AnyEncryptedV3Column, + min: unknown, + max: unknown, + opts?: EncryptedCallOpts, + ): Promise { + const operator = negate ? 'notBetween' : 'between' + const { ctx, ident } = resolve(column, operator) + gate(ctx, operator, 'orderAndRange') + const [encMin, encMax] = await Promise.all([ + encryptOperand(ctx, min, operator, opts), + encryptOperand(ctx, max, operator, opts), + ]) + return v3PrismaDialect.between(sql, ident, encMin, encMax, negate) + } + + async function list( + negate: boolean, + column: AnyEncryptedV3Column, + values: ReadonlyArray, + opts?: EncryptedCallOpts, + ): Promise { + const operator = negate ? 'notIn' : 'in' + const { ctx, ident } = resolve(column, operator) + gate(ctx, operator, 'equality') + if (values.length === 0) { + throw new EncryptionOperatorError( + `[prisma v3]: "${operator}" requires a non-empty list of values for column "${ctx.dbName}"`, + { columnName: ctx.dbName, tableName: ctx.tableName, operator }, + ) + } + const operands = await mapWithConcurrency( + values, + MAX_LIST_CONCURRENCY, + (value) => encryptOperand(ctx, value, operator, opts), + ) + return negate + ? v3PrismaDialect.list(sql, 'neq', 'AND', ident, operands) + : v3PrismaDialect.list(sql, 'eq', 'OR', ident, operands) + } + + return { + eq: (column, value, opts) => binary('eq', 'equality', column, value, opts), + ne: (column, value, opts) => binary('neq', 'equality', column, value, opts), + gt: (column, value, opts) => + binary('gt', 'orderAndRange', column, value, opts), + gte: (column, value, opts) => + binary('gte', 'orderAndRange', column, value, opts), + lt: (column, value, opts) => + binary('lt', 'orderAndRange', column, value, opts), + lte: (column, value, opts) => + binary('lte', 'orderAndRange', column, value, opts), + between: (column, min, max, opts) => range(false, column, min, max, opts), + notBetween: (column, min, max, opts) => range(true, column, min, max, opts), + contains: (column, value, opts) => + binary('contains', 'freeTextSearch', column, value, opts), + in: (column, values, opts) => list(false, column, values, opts), + notIn: (column, values, opts) => list(true, column, values, opts), + orderBy: (column, direction = 'asc') => { + const { ctx, ident } = resolve(column, 'orderBy') + gate(ctx, 'orderBy', 'orderAndRange') + return v3PrismaDialect.ordTerm( + sql, + ident, + direction === 'desc' ? 'DESC' : 'ASC', + ) + }, + isNull: (column) => { + const { ident } = resolve(column, 'isNull') + return v3PrismaDialect.nullCheck(sql, ident, false) + }, + isNotNull: (column) => { + const { ident } = resolve(column, 'isNotNull') + return v3PrismaDialect.nullCheck(sql, ident, true) + }, + } +} diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 2380c8c4..bbfd68f5 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -16,6 +16,7 @@ export default defineConfig([ 'src/secrets/index.ts', 'src/schema/index.ts', 'src/eql/v3/index.ts', + 'src/eql/v3/prisma/index.ts', 'src/drizzle/index.ts', 'src/dynamodb/index.ts', 'src/supabase/index.ts',