Skip to content

Commit faa3976

Browse files
calvinbrewerclaude
andcommitted
feat(prisma-next): v3 query operators — eql_v3.* lowering + capability gating
Add cipherstashV3QueryOperations() (src/v3/operators-v3.ts): the EQL v3 operator set registered by the v3 extension descriptor only (decision 1b — same cipherstash* method names as v2, never co-registered; the flat OperationRegistry collision is pinned by test). Lowering matches the stack-drizzle v3 dialect byte-for-byte: operands are ciphertext-free query terms cast to the column domain's eql_v3.query_<domain> type (eql_v3.eq/neq/gt/gte/lt/lte/contains, self-parenthesised (gte AND lte) range, OR-of-eq membership), JSON containment is OPERATOR(public.@>) with the irregular ::eql_v3.query_jsonb cast (cipherstashJsonContains, empty-needle guarded), and ordering extracts eql_v3.ord_term / ord_term_ore by the domain's flavour via the free-standing cipherstashV3Asc/Desc helpers. Operands are bound as pg/text@1 params (bare $N; the template supplies the query cast — the column codec's storage-domain cast would fail the query term's CHECK) and each envelope is routing-key stamped plus marked with its queryType (markV3QueryTerm / v3QueryTermTypeOf), the seam Task 7's SDK adapter uses to route WHERE operands through encryptQuery instead of storage bulkEncrypt. Every operator gates on the concrete domain's catalog capabilities and throws the v3-owned EncryptionOperatorError naming column, domain, operator, and missing capability. v3 codec descriptors are pinned to cipherstash:*-only traits alongside the v2 equality-trait regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d59764d commit faa3976

8 files changed

Lines changed: 1903 additions & 0 deletions

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

Lines changed: 813 additions & 0 deletions
Large diffs are not rendered by default.

packages/prisma-next/test/equality-trait-removal.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { createCipherstashStringCodec } from '../src/execution/codec-runtime'
3636
import { createParameterizedCodecDescriptors } from '../src/execution/parameterized'
3737
import type { CipherstashSdk } from '../src/execution/sdk'
3838
import { cipherstashStringCodecMetadata } from '../src/extension-metadata/codec-metadata'
39+
import { createV3CodecDescriptors } from '../src/v3/codec-runtime-v3'
3940

4041
function emptySdk(): CipherstashSdk {
4142
return {
@@ -90,6 +91,26 @@ describe('cipherstash codec: no `equality` trait', () => {
9091
})
9192
})
9293

94+
describe('cipherstash v3 codec descriptors: no framework built-in traits', () => {
95+
it('every v3 descriptor advertises only cipherstash:* traits — never the framework built-in `equality`', () => {
96+
// Same footgun as v2, per-domain: a framework built-in trait on a
97+
// v3 codec descriptor would re-attach the built-in `eq` (SQL `=`)
98+
// to encrypted columns, which silently returns zero rows against
99+
// nondeterministic ciphertexts. Storage-only domains legitimately
100+
// carry an EMPTY trait list (they answer no operator at all), so
101+
// the invariant is subset-of-`cipherstash:*`, not non-empty.
102+
const descriptors = createV3CodecDescriptors(emptySdk())
103+
expect(descriptors.length).toBeGreaterThan(0)
104+
for (const descriptor of descriptors) {
105+
const traits: ReadonlyArray<string> = descriptor.traits ?? []
106+
for (const trait of traits) {
107+
expect(String(trait).startsWith('cipherstash:')).toBe(true)
108+
}
109+
expect(traits).not.toContain('equality')
110+
}
111+
})
112+
})
113+
93114
describe('cipherstash columns: framework built-in `eq` is not reachable', () => {
94115
it('documents the gating contract — built-in `eq` requires `equality` in column traits', () => {
95116
// This test pins the contract that `cipherstash/string@1` columns
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* v3 operator capability gating + registration constraints.
3+
*
4+
* Every v3 operator gates on the CONCRETE domain's query capabilities
5+
* (derived from the catalog by codec id) before building any AST, and
6+
* throws `EncryptionOperatorError` naming the column, domain, operator,
7+
* and missing capability. Trait dispatch alone is not enough: traits
8+
* are per-codec, and a caller reaching the operator descriptor directly
9+
* (or through a custom builder) must still be stopped when the domain
10+
* cannot answer the operator.
11+
*
12+
* Also pins decision 1b's registration constraint: the v3 descriptor
13+
* stands alone (a v3-only adapter builds cleanly), and co-registering
14+
* the v2 and v3 descriptors throws — the framework's flat
15+
* `OperationRegistry` rejects two descriptors sharing the
16+
* `cipherstashEq` method name. v2 and v3 are separate entry points,
17+
* never composed into one client.
18+
*/
19+
20+
import { describe, expect, it } from 'vitest'
21+
import { createCipherstashRuntimeDescriptor } from '../../src/exports/runtime'
22+
import {
23+
cipherstashV3Asc,
24+
cipherstashV3QueryOperations,
25+
EncryptionOperatorError,
26+
} from '../../src/v3/operators-v3'
27+
import {
28+
assembleV3ExecutionContext,
29+
BOOLEAN_CODEC_ID,
30+
callOperator,
31+
columnAccessorV3,
32+
emptySdk,
33+
getOperator,
34+
JSON_CODEC_ID,
35+
makeV3Adapter,
36+
TABLE,
37+
TEXT_EQ_CODEC_ID,
38+
TEXT_STORAGE_CODEC_ID,
39+
v3RuntimeDescriptor,
40+
} from './operator-lowering-v3.helpers'
41+
42+
describe('v3 operator capability gating', () => {
43+
it('equality requires the equality capability — storage-only eql_v3_text rejects cipherstashEq', () => {
44+
expect(() =>
45+
callOperator(
46+
getOperator('cipherstashEq'),
47+
columnAccessorV3(TABLE, 'note', TEXT_STORAGE_CODEC_ID),
48+
'x',
49+
),
50+
).toThrow(EncryptionOperatorError)
51+
})
52+
53+
it('names the column, domain, operator, and missing capability in the diagnostic', () => {
54+
try {
55+
callOperator(
56+
getOperator('cipherstashEq'),
57+
columnAccessorV3(TABLE, 'note', TEXT_STORAGE_CODEC_ID),
58+
'x',
59+
)
60+
expect.unreachable('cipherstashEq on a storage-only column must throw')
61+
} catch (error) {
62+
expect(error).toBeInstanceOf(EncryptionOperatorError)
63+
const operatorError = error as EncryptionOperatorError
64+
expect(operatorError.message).toContain('cipherstashEq')
65+
expect(operatorError.message).toContain('equality')
66+
expect(operatorError.message).toContain('"note"')
67+
expect(operatorError.message).toContain('public.eql_v3_text')
68+
expect(operatorError.context).toEqual({
69+
columnName: 'note',
70+
tableName: TABLE,
71+
operator: 'cipherstashEq',
72+
})
73+
}
74+
})
75+
76+
it('comparison requires order/range — text_eq rejects cipherstashGt', () => {
77+
expect(() =>
78+
callOperator(
79+
getOperator('cipherstashGt'),
80+
columnAccessorV3(TABLE, 'nickname', TEXT_EQ_CODEC_ID),
81+
'x',
82+
),
83+
).toThrow(/order\/range/)
84+
})
85+
86+
it('free-text requires the match index — text_eq rejects cipherstashIlike', () => {
87+
expect(() =>
88+
callOperator(
89+
getOperator('cipherstashIlike'),
90+
columnAccessorV3(TABLE, 'nickname', TEXT_EQ_CODEC_ID),
91+
'x',
92+
),
93+
).toThrow(/free-text/)
94+
})
95+
96+
it('JSON containment requires searchableJson — text_eq rejects cipherstashJsonContains', () => {
97+
expect(() =>
98+
callOperator(
99+
getOperator('cipherstashJsonContains'),
100+
columnAccessorV3(TABLE, 'nickname', TEXT_EQ_CODEC_ID),
101+
{ role: 'admin' },
102+
),
103+
).toThrow(/JSON containment/)
104+
})
105+
106+
it('eql_v3_json is searchableJson-only — rejects cipherstashEq', () => {
107+
expect(() =>
108+
callOperator(
109+
getOperator('cipherstashEq'),
110+
columnAccessorV3(TABLE, 'payload', JSON_CODEC_ID),
111+
{ role: 'admin' },
112+
),
113+
).toThrow(/equality/)
114+
})
115+
116+
it('storage-only eql_v3_boolean rejects every search operator', () => {
117+
for (const method of [
118+
'cipherstashEq',
119+
'cipherstashNe',
120+
'cipherstashGt',
121+
'cipherstashIlike',
122+
'cipherstashJsonContains',
123+
]) {
124+
expect(() =>
125+
callOperator(
126+
getOperator(method),
127+
columnAccessorV3(TABLE, 'active', BOOLEAN_CODEC_ID),
128+
true,
129+
),
130+
).toThrow(EncryptionOperatorError)
131+
}
132+
})
133+
134+
it('ordering helpers gate on order/range — text_eq rejects cipherstashV3Asc', () => {
135+
expect(() =>
136+
cipherstashV3Asc(columnAccessorV3(TABLE, 'nickname', TEXT_EQ_CODEC_ID)),
137+
).toThrow(/order\/range/)
138+
})
139+
140+
it('rejects a non-v3 codec id (v2 columns are the wrong entry point)', () => {
141+
expect(() =>
142+
callOperator(
143+
getOperator('cipherstashEq'),
144+
columnAccessorV3(TABLE, 'email', 'cipherstash/string@1'),
145+
'x',
146+
),
147+
).toThrow(/not a .*v3 domain/)
148+
})
149+
150+
it('rejects a self expression with no codec binding', () => {
151+
expect(() =>
152+
callOperator(
153+
getOperator('cipherstashEq'),
154+
{
155+
buildAst: () => {
156+
throw new Error('unreachable')
157+
},
158+
},
159+
'x',
160+
),
161+
).toThrow(/missing a CodecRef/)
162+
})
163+
})
164+
165+
describe('v3 descriptor registration (decision 1b)', () => {
166+
it('a v3-only adapter builds and registers its operation set cleanly', () => {
167+
expect(() => makeV3Adapter()).not.toThrow()
168+
// The execution context is what assembles the flat operation
169+
// registry from `queryOperations()` — a v3-only stack registers the
170+
// full `cipherstash*` method set without collision.
171+
const registered = Object.keys(
172+
assembleV3ExecutionContext().queryOperations.entries(),
173+
)
174+
expect(registered).toEqual(
175+
expect.arrayContaining([...Object.keys(cipherstashV3QueryOperations())]),
176+
)
177+
})
178+
179+
it('co-registering the v2 and v3 descriptors throws on the shared method names', () => {
180+
// Both descriptors define `cipherstashEq` (and eleven siblings); the
181+
// flat, method-keyed OperationRegistry disallows override. This is
182+
// WHY v2 and v3 are separate entry points that are never composed
183+
// into one client. The registry is assembled when the execution
184+
// context is built against a contract, so the collision surfaces
185+
// there rather than at adapter construction.
186+
expect(() =>
187+
assembleV3ExecutionContext([
188+
createCipherstashRuntimeDescriptor({ sdk: emptySdk() }),
189+
v3RuntimeDescriptor(),
190+
]),
191+
).toThrow(/already registered/)
192+
})
193+
})

0 commit comments

Comments
 (0)