Skip to content

Commit 6d17336

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): EQL-derived v3 operator vocabulary with a guarded eqlMatch
Rename the v3 query operations to the eql_v3.* function vocabulary they lower to — eqlEq/eqlNeq/eqlIn/eqlNotIn/eqlGt/eqlGte/eqlLt/eqlLte/ eqlBetween/eqlNotBetween/eqlJsonContains, ordering via eqlAsc/eqlDesc. The eql prefix (not bare eq/gt) is required because the framework's native scope-field methods already claim eq/neq/gt/…, which lower to plain SQL comparisons (PR #655 review, items 1+2). The v2 surface keeps its cipherstash* names; the sets are now disjoint. Free-text search is eqlMatch, not ilike: eql_v3.contains is fuzzy bloom token matching, not SQL pattern semantics. Two guards run before encryption: wildcard normalisation (leading/trailing % stripped, interior % or any _ rejected) and the shared adapter-kit matchNeedleError short-needle rejection. cipherstashNotIlike is removed outright — negating a may-false-positive bloom test silently drops matching rows. Type-level dispatch mirrors the split: v3 codec entries carry only cipherstash:v3-* marker traits, so eql* methods never surface on v2 columns and cipherstash* methods never surface on v3 columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9c96643 commit 6d17336

20 files changed

Lines changed: 767 additions & 393 deletions

.changeset/eql-v3-prisma-next.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
- Text: `EncryptedText` (storage), `EncryptedTextEq`, `EncryptedTextOrd` (eq + order/range), `EncryptedTextMatch` (free-text), `EncryptedTextSearch` (eq + free-text + order/range).
99
- Scalars (Integer, Smallint, BigInt, Numeric, Real, Double, Date, Timestamp): `Encrypted<Fam>` (storage), `Encrypted<Fam>Eq`, `Encrypted<Fam>Ord`.
1010
- `EncryptedBoolean` — storage-only (`public.eql_v3_boolean`); there is no boolean equality constructor.
11-
- `EncryptedJson` — searchable encrypted JSONB (`public.eql_v3_json`, `ste_vec`), queried with `cipherstashJsonContains` (`@>` containment).
11+
- `EncryptedJson` — searchable encrypted JSONB (`public.eql_v3_json`, `ste_vec`), queried with `eqlJsonContains` (`@>` containment). Selector querying (comparing the value at a JSONPath) is tracked in #677.
1212
- **Impossible capability combinations have no constructor** (e.g. text equality + free-text without order/range) — they are unrepresentable, not runtime errors.
1313
- **BigInt is a first-class v3 family** (`EncryptedBigInt` / `EncryptedBigIntEq` / `EncryptedBigIntOrd`, JS `bigint` plaintext, backed by `public.eql_v3_bigint*`).
1414
- Use the `*V2` constructors (`EncryptedStringV2`, `EncryptedDoubleV2`, `EncryptedBigIntV2`, `EncryptedDateV2`, `EncryptedBooleanV2`, `EncryptedJsonV2`) to keep EQL v2 columns. A client is v2 or v3 — the two runtime descriptors are never co-registered.
1515
- New `@cipherstash/prisma-next/v3` entry point: `cipherstashFromStackV3({ contractJson })` builds the v3 runtime descriptor, bulk-encrypt middleware, and a stack `EncryptionV3` client from the emitted contract.
16-
- Query operators (`cipherstashEq`, `cipherstashGt`, `cipherstashBetween`, `cipherstashInArray`, …) lower to `eql_v3.*` functions with operands cast to the domain's query type (`$n::eql_v3.query_<domain>`); `cipherstashIlike` maps to `eql_v3.contains` (bloom-filter token containment, not SQL `LIKE`); ordering uses `eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering flavour. The domains are `public.eql_v3_*`; the operator functions live in the `eql_v3` schema.
16+
- Query operators use an **EQL-derived vocabulary** (`eqlEq`, `eqlNeq`, `eqlIn`, `eqlNotIn`, `eqlGt`, `eqlGte`, `eqlLt`, `eqlLte`, `eqlBetween`, `eqlNotBetween`, `eqlJsonContains`; ordering via `eqlAsc` / `eqlDesc`), lowering to the same-named `eql_v3.*` functions with operands cast to the domain's query type (`$n::eql_v3.query_<domain>`); ordering uses `eql_v3.ord_term` / `eql_v3.ord_term_ore` by the column's ordering flavour. The domains are `public.eql_v3_*`; the operator functions live in the `eql_v3` schema. (The v2 surface keeps its `cipherstash*` names.)
17+
- Free-text search is **`eqlMatch`** — fuzzy bloom token matching (`eql_v3.contains`), deliberately NOT named after SQL `ILIKE`: matching is case-insensitive, order/multiplicity-insensitive, and one-sided (may false-positive). Two guards run before encryption: SQL wildcards are normalised (leading/trailing `%` stripped; interior `%` or any `_` rejected), and needles the column's match index cannot answer (empty / below the tokenizer length) are rejected via the shared `matchNeedleError` guard. There is **no negated match operator** — negating a may-false-positive bloom test would silently drop matching rows.
1718
- A new baseline migration `20260601T0100_install_eql_v3_bundle` (invariant `cipherstash:install-eql-v3-bundle-v1`) installs the `public.eql_v3_*` domains and `eql_v3.*` functions from the pinned `@cipherstash/eql` release. Regenerate contracts and run migrations after changing constructors.
1819
- **The v3 ORM surface is fully wired end-to-end** (proven by converting `examples/prisma`):
19-
- The generated `contract.d.ts` type surface covers every v3 codec id: `CodecTypes` gains all 40 `cipherstash/eql-v3/*@1` entries (envelope outputs — `number`-castAs domains decode to the new `EncryptedNumber` — plus trait-accurate operator visibility), and `QueryOperationTypes` gains `cipherstashJsonContains` and surfaces `cipherstashEq` / `cipherstashIlike` on v3 columns via type-level `cipherstash:v3-*` marker traits. Storage-only domains (including `EncryptedBoolean`) surface no operator methods at the type level, matching the runtime gate.
20+
- The generated `contract.d.ts` type surface covers every v3 codec id: `CodecTypes` gains all 40 `cipherstash/eql-v3/*@1` entries (envelope outputs — `number`-castAs domains decode to the new `EncryptedNumber` — plus trait-accurate operator visibility), and `QueryOperationTypes` gains the `eql*` operator set, surfaced on v3 columns via type-level `cipherstash:v3-*` marker traits (the v2 `cipherstash*` methods never appear on v3 columns, and vice-versa). Storage-only domains (including `EncryptedBoolean`) surface no operator methods at the type level, matching the runtime gate.
2021
- The v3 runtime descriptor now presents the **pack id** (`cipherstash`) with v3's own version, so `postgres<Contract>({ extensions })` accepts contracts emitted by the cipherstash extension pack instead of failing with `RUNTIME.MISSING_EXTENSION_PACK`.
2122
- Every v3 codec id registers a control-plane `expandNativeType` hook that strips the `public.` qualifier — `prisma-next migration plan` now renders `CREATE TABLE` columns as bare domain names (`eql_v3_bigint_ord`), matching what introspection reports, with **no `add_search_config` ops** (v3 domains carry their own index metadata). No `onFieldEvent` is registered for v3.
2223
- The v3 bundle baseline migration op is reclassified `data` (it is a contract-shape-neutral self-edge; the aggregate integrity checker rejects self-edges without a data-class op), unblocking `prisma-next migration plan` / `migrate` in consuming apps.

packages/prisma-next/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ Declare encrypted columns directly in `schema.prisma`, and the framework's migra
1010

1111
- 🔒 Six encrypted column types — `string`, `double`, `bigint`, `date`, `boolean`, `json`
1212
- 🔍 Searchable encryption — equality, free-text search, range, order, JSON path and containment
13-
- 🎯 17 type-safe query operators (`cipherstashEq`, `cipherstashIlike`, `cipherstashGt`, `cipherstashAsc`, …)
13+
- 🎯 Type-safe query operators — EQL v3 uses the EQL-derived `eql*` vocabulary (`eqlEq`, `eqlMatch`, `eqlGt`, `eqlAsc`, …); the legacy v2 surface keeps its `cipherstash*` names
1414
- ⚡ Bulk encrypt / bulk decrypt coalescing — one SDK round-trip per `(table, column)` group per query
15-
- 🧩 One-call setup via `cipherstashFromStack({ contractJson })` — no duplicate stack schema to maintain
15+
- 🧩 One-call setup via `cipherstashFromStackV3({ contractJson })` (v2: `cipherstashFromStack`) — no duplicate stack schema to maintain
1616
- 🛡️ Plaintext redaction on every implicit serialisation path (`toJSON`, `toString`, `util.inspect`, …)
1717

1818
## Installation
@@ -91,7 +91,8 @@ See the [full documentation](https://cipherstash.com/docs/stack/cipherstash/encr
9191

9292
| Subpath | Purpose |
9393
| ---------------- | ------------------------------------------------------------------------------------------------------ |
94-
| `./stack` | One-call setup against `@cipherstash/stack`: `cipherstashFromStack`, `deriveStackSchemas`, `createCipherstashSdk` |
94+
| `./v3` | The complete EQL v3 surface: `cipherstashFromStackV3`, the `eql*` query operations, `eqlAsc`/`eqlDesc`, envelopes, middleware, SDK adapter |
95+
| `./stack` | One-call setup against `@cipherstash/stack` (EQL v2): `cipherstashFromStack`, `deriveStackSchemas`, `createCipherstashSdk` |
9596
| `./control` | `SqlControlExtensionDescriptor` (contract space + pack meta + codec lifecycle hooks) |
9697
| `./runtime` | Six envelope classes + `CipherstashSdk` + codec runtime + `decryptAll` + four free-standing helpers |
9798
| `./middleware` | `bulkEncryptMiddleware(sdk)` |

packages/prisma-next/src/exports/runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ export type {
146146
} from '../v3/envelope-number'
147147
export { EncryptedNumber } from '../v3/envelope-number'
148148
export {
149-
cipherstashV3Asc,
150-
cipherstashV3Desc,
151149
cipherstashV3QueryOperations,
150+
eqlAsc,
151+
eqlDesc,
152152
} from '../v3/operators-v3'
153153
export {
154154
EncryptionOperatorError,

packages/prisma-next/src/exports/v3.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ export { assertV3SchemasAgree } from '../v3/from-stack-v3-validate'
5757
// Query operators (registered by the runtime descriptor) + the
5858
// free-standing ORDER BY helpers.
5959
export {
60-
cipherstashV3Asc,
61-
cipherstashV3Desc,
6260
cipherstashV3QueryOperations,
61+
eqlAsc,
62+
eqlDesc,
6363
} from '../v3/operators-v3'
6464
// The query-term seam (consumed by custom CipherstashSdk implementations).
6565
export {

packages/prisma-next/src/types/codec-types.ts

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -59,42 +59,24 @@ import type { EncryptedNumber } from '../v3/envelope-number'
5959
// EQL v3 type-level trait vocabulary
6060
// ---------------------------------------------------------------------------
6161
//
62-
// The SHARED trait strings (`cipherstash:equality`, `cipherstash:
63-
// order-and-range`, `cipherstash:free-text-search`) appear on v3
64-
// entries exactly like on v2 entries: the trait-dispatched operator
65-
// methods (`cipherstashNe`, `cipherstashGt`, `cipherstashBetween`, …)
66-
// exist in BOTH runtime generations under the same names (decision 1b
67-
// keeps the method-name surface identical), so sharing the dispatch
68-
// trait is correct.
69-
//
7062
// The `cipherstash:v3-*` strings are TYPE-LEVEL MARKERS with no
71-
// runtime counterpart. They exist because three methods differ by
72-
// generation:
63+
// runtime counterpart. The v2 and v3 surfaces have DISJOINT method
64+
// names (`cipherstash*` vs the EQL-derived `eql*` — PR #655 review),
65+
// so v3 codec entries carry ONLY the v3 markers and none of the shared
66+
// v2 trait strings:
7367
//
74-
// - `cipherstashEq` / `cipherstashIlike` are codec-id-pinned to
75-
// `cipherstash/string@1` in the v2 runtime (legacy single-codec
76-
// registration) but trait-dispatched in the v3 runtime. Their
77-
// type-level `self` in `operation-types.ts` therefore declares
78-
// BOTH a `codecId` (matched for v2 string columns) and a
79-
// `traits: ['cipherstash:v3-equality' / 'cipherstash:v3-free-
80-
// text-search']` marker (matched for v3 columns) — the
81-
// framework's `OpMatchesField` tries codec-id first and falls
82-
// through to traits.
83-
// - `cipherstashJsonContains` exists ONLY in the v3 runtime, and
84-
// `cipherstashJsonbPathExists` ONLY in the v2 runtime. The v3
85-
// JSON entry deliberately carries `cipherstash:v3-searchable-json`
86-
// INSTEAD of the shared `cipherstash:searchable-json` so each
87-
// method surfaces only on the generation whose runtime registers
88-
// it.
68+
// - every `eql*` operator in `operation-types.ts` dispatches on a
69+
// v3 marker, so it surfaces only on v3 columns;
70+
// - every v2 operator dispatches on a shared v2 trait (or the legacy
71+
// `cipherstash/string@1` codec-id pin), which no v3 entry carries,
72+
// so the `cipherstash*` methods never surface on v3 columns.
8973
//
9074
// Type-level visibility therefore never advertises a method the
9175
// column's runtime cannot dispatch.
9276
type V3TraitsNone = never
93-
type V3TraitsEq = 'cipherstash:equality' | 'cipherstash:v3-equality'
94-
type V3TraitsOrd = V3TraitsEq | 'cipherstash:order-and-range'
95-
type V3TraitsMatch =
96-
| 'cipherstash:free-text-search'
97-
| 'cipherstash:v3-free-text-search'
77+
type V3TraitsEq = 'cipherstash:v3-equality'
78+
type V3TraitsOrd = V3TraitsEq | 'cipherstash:v3-order-and-range'
79+
type V3TraitsMatch = 'cipherstash:v3-free-text-search'
9880
type V3TraitsSearch = V3TraitsOrd | V3TraitsMatch
9981
type V3TraitsJson = 'cipherstash:v3-searchable-json'
10082

packages/prisma-next/src/types/operation-types.ts

Lines changed: 111 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*
99
* - Single-codec entries (`cipherstashEq`, `cipherstashIlike`,
1010
* `cipherstashNotIlike`, `cipherstashJsonbPathExists`) declare
11-
* `self: { codecId: '<id>' }`. The framework's `OpMatchesField`
11+
* `self: { codecId: '<id>' }` (v2 legacy registrations). The framework's `OpMatchesField`
1212
* direct-codec-id branch surfaces the method on columns whose
1313
* codec id is the literal — no consumer-side `CodecTypes`
1414
* augmentation needed.
@@ -86,15 +86,14 @@ type SearchableJsonTraits = readonly ['cipherstash:searchable-json']
8686
/**
8787
* v3 TYPE-LEVEL marker traits (no runtime counterpart) — carried only
8888
* by the v3 codec entries in `codec-types.ts` (see the vocabulary
89-
* comment there). Three methods differ by runtime generation:
90-
* `cipherstashEq` / `cipherstashIlike` are codec-id-pinned legacy
91-
* registrations in v2 but trait-dispatched in v3, and
92-
* `cipherstashJsonContains` (v3) / `cipherstashJsonbPathExists` (v2)
93-
* exist in only one generation each. Dispatching those on the marker
94-
* traits keeps type-level visibility exactly aligned with what each
95-
* column's runtime can actually execute.
89+
* comment there). The v2 and v3 surfaces have DISJOINT method names
90+
* (`cipherstash*` vs the EQL-derived `eql*`), so every v3 operator
91+
* dispatches on a v3 marker and every v2 operator on the shared v2
92+
* trait (or legacy codec-id pin) — type-level visibility stays exactly
93+
* aligned with what each column's runtime can actually execute.
9694
*/
9795
type V3EqualityMarker = readonly ['cipherstash:v3-equality']
96+
type V3OrderAndRangeMarker = readonly ['cipherstash:v3-order-and-range']
9897
type V3FreeTextSearchMarker = readonly ['cipherstash:v3-free-text-search']
9998
type V3SearchableJsonMarker = readonly ['cipherstash:v3-searchable-json']
10099

@@ -138,31 +137,22 @@ type AnyExpressionLike = Expression<{
138137
export type QueryOperationTypes<CT extends CodecTypesBase> =
139138
CT extends CodecTypesBase
140139
? {
141-
// `self` carries BOTH dispatch forms: `OpMatchesField` tries the
142-
// codec-id branch first (matches the v2 legacy single-codec
143-
// registration on `cipherstash/string@1`) and falls through to
144-
// the traits branch (matches v3 columns via the type-level
145-
// `cipherstash:v3-*` markers). v2 non-string columns match
146-
// neither — their runtime has no `cipherstashEq` to dispatch.
147-
// The comparand is `unknown` (not `pg/text@1`) because v3
148-
// equality spans every equality-capable domain family (bigint,
149-
// date, numeric, …), and the runtime coerces + encrypts the
150-
// operand per the column's castAs.
140+
// -------------------------------------------------------------
141+
// v2 legacy surface (`cipherstash*`). `cipherstashEq` /
142+
// `cipherstashIlike` are codec-id-pinned to the v2 string codec
143+
// (legacy single-codec registrations); the rest dispatch on the
144+
// shared v2 traits, which v3 codec entries no longer carry —
145+
// so none of these surface on a v3 column.
146+
// -------------------------------------------------------------
151147
readonly cipherstashEq: {
152-
readonly self: {
153-
readonly codecId: CipherstashStringCodec
154-
readonly traits: V3EqualityMarker
155-
}
148+
readonly self: { readonly codecId: CipherstashStringCodec }
156149
readonly impl: (
157150
self: AnyExpressionLike,
158151
other: unknown,
159152
) => PgBoolReturn
160153
}
161154
readonly cipherstashIlike: {
162-
readonly self: {
163-
readonly codecId: CipherstashStringCodec
164-
readonly traits: V3FreeTextSearchMarker
165-
}
155+
readonly self: { readonly codecId: CipherstashStringCodec }
166156
readonly impl: (
167157
self: AnyExpressionLike,
168158
pattern: CodecExpression<'pg/text@1', boolean, CT>,
@@ -244,13 +234,102 @@ export type QueryOperationTypes<CT extends CodecTypesBase> =
244234
readonly self: { readonly traits: SearchableJsonTraits }
245235
readonly impl: (self: AnyExpressionLike, path: string) => PgBoolReturn
246236
}
237+
// -------------------------------------------------------------
238+
// v3 surface (`eql*`, EQL-derived vocabulary — PR #655 review).
239+
// Every entry dispatches on a `cipherstash:v3-*` marker, which
240+
// only v3 codec entries carry, so nothing here surfaces on a
241+
// v2 column (whose runtime has no `eql*` method to dispatch) —
242+
// and, symmetrically, the v2 entries above never surface on v3
243+
// columns. The comparand is `unknown` because each operator
244+
// spans every capable domain family (bigint, date, numeric, …)
245+
// and the runtime coerces + encrypts per the column's castAs.
246+
// -------------------------------------------------------------
247+
readonly eqlEq: {
248+
readonly self: { readonly traits: V3EqualityMarker }
249+
readonly impl: (
250+
self: AnyExpressionLike,
251+
other: unknown,
252+
) => PgBoolReturn
253+
}
254+
readonly eqlNeq: {
255+
readonly self: { readonly traits: V3EqualityMarker }
256+
readonly impl: (
257+
self: AnyExpressionLike,
258+
other: unknown,
259+
) => PgBoolReturn
260+
}
261+
readonly eqlIn: {
262+
readonly self: { readonly traits: V3EqualityMarker }
263+
readonly impl: (
264+
self: AnyExpressionLike,
265+
values: readonly unknown[],
266+
) => PgBoolReturn
267+
}
268+
readonly eqlNotIn: {
269+
readonly self: { readonly traits: V3EqualityMarker }
270+
readonly impl: (
271+
self: AnyExpressionLike,
272+
values: readonly unknown[],
273+
) => PgBoolReturn
274+
}
275+
readonly eqlGt: {
276+
readonly self: { readonly traits: V3OrderAndRangeMarker }
277+
readonly impl: (
278+
self: AnyExpressionLike,
279+
other: unknown,
280+
) => PgBoolReturn
281+
}
282+
readonly eqlGte: {
283+
readonly self: { readonly traits: V3OrderAndRangeMarker }
284+
readonly impl: (
285+
self: AnyExpressionLike,
286+
other: unknown,
287+
) => PgBoolReturn
288+
}
289+
readonly eqlLt: {
290+
readonly self: { readonly traits: V3OrderAndRangeMarker }
291+
readonly impl: (
292+
self: AnyExpressionLike,
293+
other: unknown,
294+
) => PgBoolReturn
295+
}
296+
readonly eqlLte: {
297+
readonly self: { readonly traits: V3OrderAndRangeMarker }
298+
readonly impl: (
299+
self: AnyExpressionLike,
300+
other: unknown,
301+
) => PgBoolReturn
302+
}
303+
readonly eqlBetween: {
304+
readonly self: { readonly traits: V3OrderAndRangeMarker }
305+
readonly impl: (
306+
self: AnyExpressionLike,
307+
low: unknown,
308+
high: unknown,
309+
) => PgBoolReturn
310+
}
311+
readonly eqlNotBetween: {
312+
readonly self: { readonly traits: V3OrderAndRangeMarker }
313+
readonly impl: (
314+
self: AnyExpressionLike,
315+
low: unknown,
316+
high: unknown,
317+
) => PgBoolReturn
318+
}
319+
// Fuzzy free-text token match (`eql_v3.contains`) — NOT SQL
320+
// pattern matching; needles are guarded at lowering time
321+
// (wildcards, short needles). No negated form: the bloom test
322+
// may false-positive, so its negation would false-negative.
323+
readonly eqlMatch: {
324+
readonly self: { readonly traits: V3FreeTextSearchMarker }
325+
readonly impl: (
326+
self: AnyExpressionLike,
327+
needle: unknown,
328+
) => PgBoolReturn
329+
}
247330
// v3-only: encrypted jsonb `@>` containment on `eql_v3_json`
248-
// columns. Dispatches on the v3 marker trait (NOT the shared
249-
// `cipherstash:searchable-json`) so it never surfaces on v2
250-
// JSON columns, whose runtime does not register the method —
251-
// and, symmetrically, `cipherstashJsonbPathExists` above never
252-
// surfaces on v3 columns.
253-
readonly cipherstashJsonContains: {
331+
// columns.
332+
readonly eqlJsonContains: {
254333
readonly self: { readonly traits: V3SearchableJsonMarker }
255334
readonly impl: (
256335
self: AnyExpressionLike,

0 commit comments

Comments
 (0)