Skip to content

Commit ea64929

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): v3 per-domain authoring constructors + *V2 aliases
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cdf40f5 commit ea64929

13 files changed

Lines changed: 1140 additions & 435 deletions

packages/prisma-next/src/contract-authoring.ts

Lines changed: 346 additions & 196 deletions
Large diffs are not rendered by default.

packages/prisma-next/src/exports/column-types.ts

Lines changed: 171 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,35 @@
11
/**
22
* TS contract factories for cipherstash-encrypted columns.
33
*
4-
* Counterparts to the PSL constructors `cipherstash.Encrypted<Type>({...})`
5-
* registered in `../contract-authoring`. The six factories
6-
* (`encryptedString`, `encryptedDouble`, `encryptedBigInt`,
7-
* `encryptedDate`, `encryptedBoolean`, `encryptedJson`) produce the
8-
* same `ColumnTypeDescriptor` shape as their PSL counterparts, so
9-
* PSL- and TS-authored contracts emit byte-identical `contract.json`.
10-
* Pinned by the parity fixtures at
11-
* `test/integration/test/authoring/parity/cipherstash-encrypted-{string,double,bigint,date,boolean,json}/`.
4+
* ## v3: one factory per exposed domain
125
*
13-
* Every search-mode flag defaults to `true` — searchable encryption
14-
* is the legitimate default for an extension whose entire reason for
15-
* existing is to make encrypted columns queryable. Users who want
16-
* storage-only encryption opt out explicitly:
17-
* `encryptedString({ equality: false, freeTextSearch: false, orderAndRange: false })`.
18-
* Mirrors the PSL constructors' `true` defaults declared via
6+
* Counterparts to the argument-less PSL constructors
7+
* `cipherstash.Encrypted<Stem><Suffix>()` registered in
8+
* `../contract-authoring`. Each factory returns the domain's concrete
9+
* descriptor — static codec id, `public.eql_v3_*` native type, and a static
10+
* `{ castAs, capabilities }` typeParams block — identical to what its PSL
11+
* counterpart lowers to, so PSL- and TS-authored contracts emit
12+
* byte-identical `contract.json`. There are NO options: the factory IS the
13+
* capability set (`encryptedTextSearch` vs `encryptedText`, and so on).
14+
*
15+
* ## v2: legacy `*V2` aliases
16+
*
17+
* The six pre-rename factories (`encryptedStringV2`, `encryptedDoubleV2`,
18+
* `encryptedBigIntV2`, `encryptedDateV2`, `encryptedBooleanV2`,
19+
* `encryptedJsonV2`) keep their previous bodies verbatim: single optional
20+
* options object, every search-mode flag defaulting to `true` — searchable
21+
* encryption is the legitimate default for an extension whose entire reason
22+
* for existing is to make encrypted columns queryable — and the
23+
* `cipherstash/*@1` codec + `eql_v2_encrypted` native type outputs. They
24+
* mirror the `*V2` PSL constructors' `true` defaults declared via
1925
* `AuthoringArgRef.default`.
2026
*/
2127

28+
import {
29+
type AnyEncryptedV3Column,
30+
stripDomainSchema,
31+
types,
32+
} from '@cipherstash/stack/eql/v3'
2233
import {
2334
CIPHERSTASH_BIGINT_CODEC_ID,
2435
CIPHERSTASH_BOOLEAN_CODEC_ID,
@@ -28,9 +39,131 @@ import {
2839
CIPHERSTASH_STRING_CODEC_ID,
2940
EQL_V2_ENCRYPTED_TYPE,
3041
} from '../extension-metadata/constants'
42+
import { toV3CodecId, type V3CastAs } from '../v3/catalog'
43+
44+
// ---------------------------------------------------------------------------
45+
// v3 factories (derived from the concrete stack columns)
46+
// ---------------------------------------------------------------------------
47+
48+
/**
49+
* Per-column literal inference via the getters, NOT `EncryptedV3Column<infer D>`:
50+
* the stack's bundled `.d.ts` drops the private `definition` field's type, so
51+
* inferring through the class widens to the constraint. The getters' declared
52+
* return types survive d.ts bundling (see
53+
* `../extension-metadata/constants-v3.ts` for the full rationale).
54+
*/
55+
type EqlTypeFromGetter<C> = C extends { getEqlType(): infer T extends string }
56+
? T
57+
: never
58+
type CapabilitiesFromGetter<C> = C extends { getQueryCapabilities(): infer T }
59+
? T
60+
: never
61+
62+
type StripPublic<T extends string> = T extends `public.${infer D}` ? D : never
63+
64+
/**
65+
* Literal-preserving authoring descriptor, generic over the concrete stack
66+
* column `C`. `codecId`, `nativeType`, and `capabilities` are the domain's
67+
* LITERAL types (not `string` / `unknown`), so two domains' factories are
68+
* mutually non-assignable — this is the type spine (design "Type Discipline").
69+
* `castAs` is the one field the stack widens (`build(): ColumnSchema`), which
70+
* is harmless: `nativeType` already discriminates every domain. Pinned by the
71+
* type-level tests in `test/v3/column-types.test-d.ts`.
72+
*/
73+
export interface V3ColumnDescriptor<C extends AnyEncryptedV3Column> {
74+
readonly codecId: `cipherstash/eql-v3/${StripPublic<EqlTypeFromGetter<C>>}@1`
75+
readonly nativeType: EqlTypeFromGetter<C>
76+
readonly typeParams: {
77+
readonly castAs: V3CastAs
78+
readonly capabilities: CapabilitiesFromGetter<C>
79+
}
80+
}
81+
82+
/**
83+
* Build one authoring factory from a concrete stack `types.*` factory. VALUES
84+
* are read from the column (`getEqlType` / `getQueryCapabilities` / `build`) —
85+
* nothing is hardcoded — and the codec id goes through the catalog's
86+
* `toV3CodecId` (registry key VERBATIM, never a name transform), while the
87+
* literal TYPES flow from `C`. The factory set is a typed 1:1 mirror of the
88+
* exposed catalog domains, which is how "derive from the catalog" and
89+
* "distinct static type per domain" coexist: TypeScript cannot hand distinct
90+
* static types to values pulled from a runtime record, so each factory names
91+
* its stack factory. Drift is caught by the 1:1 runtime test in
92+
* `test/column-types.test.ts` (exposed factory set === catalog minus
93+
* `*_ord_ore`) and the catalog drift tests.
94+
*/
95+
function v3Authored<C extends AnyEncryptedV3Column>(
96+
make: (name: string) => C,
97+
): () => V3ColumnDescriptor<C> {
98+
const probe = make('__probe__')
99+
const nativeType = probe.getEqlType()
100+
const descriptor = {
101+
codecId: toV3CodecId(stripDomainSchema(nativeType)),
102+
nativeType,
103+
typeParams: {
104+
// Narrowing assertion, mirrors ../v3/catalog.ts: `build()` widens
105+
// `cast_as` in the bundled d.ts; the value is the domain's literal.
106+
castAs: probe.build().cast_as as V3CastAs,
107+
capabilities: probe.getQueryCapabilities(),
108+
},
109+
// Narrowing assertion: runtime strings are the literals the getters
110+
// declare; `toV3CodecId` returns `string` by signature.
111+
} as V3ColumnDescriptor<C>
112+
return () => descriptor
113+
}
114+
115+
// Each factory has a DISTINCT return type (V3ColumnDescriptor<EncryptedXColumn>);
116+
// `encryptedBigIntOrd()` is not assignable to `encryptedText()` and vice-versa.
117+
// Note the stack factory names: bigint is `types.Bigint*` (not `BigInt*`).
118+
// text family
119+
export const encryptedText = v3Authored(types.Text)
120+
export const encryptedTextEq = v3Authored(types.TextEq)
121+
export const encryptedTextOrd = v3Authored(types.TextOrd)
122+
export const encryptedTextMatch = v3Authored(types.TextMatch)
123+
export const encryptedTextSearch = v3Authored(types.TextSearch)
124+
// integer
125+
export const encryptedInteger = v3Authored(types.Integer)
126+
export const encryptedIntegerEq = v3Authored(types.IntegerEq)
127+
export const encryptedIntegerOrd = v3Authored(types.IntegerOrd)
128+
// smallint
129+
export const encryptedSmallint = v3Authored(types.Smallint)
130+
export const encryptedSmallintEq = v3Authored(types.SmallintEq)
131+
export const encryptedSmallintOrd = v3Authored(types.SmallintOrd)
132+
// bigint
133+
export const encryptedBigInt = v3Authored(types.Bigint)
134+
export const encryptedBigIntEq = v3Authored(types.BigintEq)
135+
export const encryptedBigIntOrd = v3Authored(types.BigintOrd)
136+
// numeric
137+
export const encryptedNumeric = v3Authored(types.Numeric)
138+
export const encryptedNumericEq = v3Authored(types.NumericEq)
139+
export const encryptedNumericOrd = v3Authored(types.NumericOrd)
140+
// real
141+
export const encryptedReal = v3Authored(types.Real)
142+
export const encryptedRealEq = v3Authored(types.RealEq)
143+
export const encryptedRealOrd = v3Authored(types.RealOrd)
144+
// double
145+
export const encryptedDouble = v3Authored(types.Double)
146+
export const encryptedDoubleEq = v3Authored(types.DoubleEq)
147+
export const encryptedDoubleOrd = v3Authored(types.DoubleOrd)
148+
// date
149+
export const encryptedDate = v3Authored(types.Date)
150+
export const encryptedDateEq = v3Authored(types.DateEq)
151+
export const encryptedDateOrd = v3Authored(types.DateOrd)
152+
// timestamp
153+
export const encryptedTimestamp = v3Authored(types.Timestamp)
154+
export const encryptedTimestampEq = v3Authored(types.TimestampEq)
155+
export const encryptedTimestampOrd = v3Authored(types.TimestampOrd)
156+
// boolean (storage-only)
157+
export const encryptedBoolean = v3Authored(types.Boolean)
158+
// json (encrypted JSONB, ste_vec containment)
159+
export const encryptedJson = v3Authored(types.Json)
160+
161+
// ---------------------------------------------------------------------------
162+
// v2 legacy aliases (verbatim pre-rename bodies, now named *V2)
163+
// ---------------------------------------------------------------------------
31164

32165
/**
33-
* Search-mode parameters for `encryptedString({...})`. Every flag is
166+
* Search-mode parameters for `encryptedStringV2({...})`. Every flag is
34167
* optional and defaults to `true` when omitted — searchable
35168
* encryption is the legitimate default. `orderAndRange` gives string
36169
* columns the same sortable / range-queryable surface the numeric +
@@ -53,16 +186,16 @@ export interface EncryptedStringColumnDescriptor {
53186
}
54187

55188
/**
56-
* `encryptedString({ equality?, freeTextSearch?, orderAndRange? })` —
189+
* `encryptedStringV2({ equality?, freeTextSearch?, orderAndRange? })` —
57190
* TS contract factory that lowers to a `ColumnTypeDescriptor` with
58191
* the `cipherstash/string@1` codec and the `eql_v2_encrypted`
59192
* Postgres native type. Each boolean flag becomes a `typeParams.*`
60193
* slot; all default to `true`.
61194
*
62195
* The shape matches what the PSL constructor
63-
* `cipherstash.EncryptedString({...})` lowers to, byte-for-byte.
196+
* `cipherstash.EncryptedStringV2({...})` lowers to, byte-for-byte.
64197
*/
65-
export function encryptedString(
198+
export function encryptedStringV2(
66199
options: EncryptedStringOptions = {},
67200
): EncryptedStringColumnDescriptor {
68201
return {
@@ -77,8 +210,8 @@ export function encryptedString(
77210
}
78211

79212
/**
80-
* Search-mode parameters for `encryptedDouble({...})` and
81-
* `encryptedBigInt({...})`. Both flags are optional and default to
213+
* Search-mode parameters for `encryptedDoubleV2({...})` and
214+
* `encryptedBigIntV2({...})`. Both flags are optional and default to
82215
* `true` when omitted — searchable encryption is the legitimate
83216
* default.
84217
*/
@@ -106,13 +239,13 @@ export interface EncryptedBigIntColumnDescriptor {
106239
}
107240

108241
/**
109-
* `encryptedDouble({ equality?, orderAndRange? })` — TS contract
242+
* `encryptedDoubleV2({ equality?, orderAndRange? })` — TS contract
110243
* factory that lowers to a `ColumnTypeDescriptor` with the
111244
* `cipherstash/double@1` codec and the `eql_v2_encrypted` Postgres
112245
* native type. Mirrors what
113-
* `cipherstash.EncryptedDouble({...})` lowers to byte-for-byte.
246+
* `cipherstash.EncryptedDoubleV2({...})` lowers to byte-for-byte.
114247
*/
115-
export function encryptedDouble(
248+
export function encryptedDoubleV2(
116249
options: EncryptedNumericOptions = {},
117250
): EncryptedDoubleColumnDescriptor {
118251
return {
@@ -126,10 +259,10 @@ export function encryptedDouble(
126259
}
127260

128261
/**
129-
* `encryptedBigInt({ equality?, orderAndRange? })` — TS contract
130-
* factory matching `cipherstash.EncryptedBigInt({...})`.
262+
* `encryptedBigIntV2({ equality?, orderAndRange? })` — TS contract
263+
* factory matching `cipherstash.EncryptedBigIntV2({...})`.
131264
*/
132-
export function encryptedBigInt(
265+
export function encryptedBigIntV2(
133266
options: EncryptedNumericOptions = {},
134267
): EncryptedBigIntColumnDescriptor {
135268
return {
@@ -143,7 +276,7 @@ export function encryptedBigInt(
143276
}
144277

145278
/**
146-
* Search-mode parameters for `encryptedDate({...})`. Both flags are
279+
* Search-mode parameters for `encryptedDateV2({...})`. Both flags are
147280
* optional and default to `true`.
148281
*/
149282
export interface EncryptedDateOptions {
@@ -161,10 +294,10 @@ export interface EncryptedDateColumnDescriptor {
161294
}
162295

163296
/**
164-
* `encryptedDate({ equality?, orderAndRange? })` — TS contract factory
165-
* matching `cipherstash.EncryptedDate({...})`.
297+
* `encryptedDateV2({ equality?, orderAndRange? })` — TS contract factory
298+
* matching `cipherstash.EncryptedDateV2({...})`.
166299
*/
167-
export function encryptedDate(
300+
export function encryptedDateV2(
168301
options: EncryptedDateOptions = {},
169302
): EncryptedDateColumnDescriptor {
170303
return {
@@ -178,7 +311,7 @@ export function encryptedDate(
178311
}
179312

180313
/**
181-
* Search-mode parameters for `encryptedBoolean({...})`. The flag is
314+
* Search-mode parameters for `encryptedBooleanV2({...})`. The flag is
182315
* optional and defaults to `true`. Booleans only support equality
183316
* search (no meaningful range predicate over a 2-value domain).
184317
*/
@@ -195,10 +328,10 @@ export interface EncryptedBooleanColumnDescriptor {
195328
}
196329

197330
/**
198-
* `encryptedBoolean({ equality? })` — TS contract factory matching
199-
* `cipherstash.EncryptedBoolean({...})`.
331+
* `encryptedBooleanV2({ equality? })` — TS contract factory matching
332+
* `cipherstash.EncryptedBooleanV2({...})`.
200333
*/
201-
export function encryptedBoolean(
334+
export function encryptedBooleanV2(
202335
options: EncryptedBooleanOptions = {},
203336
): EncryptedBooleanColumnDescriptor {
204337
return {
@@ -211,7 +344,7 @@ export function encryptedBoolean(
211344
}
212345

213346
/**
214-
* Search-mode parameters for `encryptedJson({...})`. Single flag —
347+
* Search-mode parameters for `encryptedJsonV2({...})`. Single flag —
215348
* `searchableJson` gates the entire `ste_vec` index family (containment
216349
* + path-extraction predicates). Defaults to `true`.
217350
*/
@@ -228,10 +361,10 @@ export interface EncryptedJsonColumnDescriptor {
228361
}
229362

230363
/**
231-
* `encryptedJson({ searchableJson? })` — TS contract factory matching
232-
* `cipherstash.EncryptedJson({...})`.
364+
* `encryptedJsonV2({ searchableJson? })` — TS contract factory matching
365+
* `cipherstash.EncryptedJsonV2({...})`.
233366
*/
234-
export function encryptedJson(
367+
export function encryptedJsonV2(
235368
options: EncryptedJsonOptions = {},
236369
): EncryptedJsonColumnDescriptor {
237370
return {

packages/prisma-next/src/extension-metadata/constants-v3.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
* closed union derived from the imported catalog: `(codec id) ⟺ (public.*
55
* domain)` is bijective, so the id set IS the catalog.
66
*/
7-
import type { AnyEncryptedV3Column, QueryCapabilities } from '@cipherstash/stack/eql/v3'
7+
import type {
8+
AnyEncryptedV3Column,
9+
QueryCapabilities,
10+
} from '@cipherstash/stack/eql/v3'
811
import {
912
CIPHERSTASH_TRAIT_EQUALITY,
1013
CIPHERSTASH_TRAIT_FREE_TEXT_SEARCH,
@@ -103,19 +106,28 @@ export const CIPHERSTASH_V3_CODEC_IDS = [
103106
// catalog gains a domain that is not pinned above, `Exclude<...>` is non-`never`
104107
// and this assignment fails to compile with the offending id in the error.
105108
type _PinnedId = (typeof CIPHERSTASH_V3_CODEC_IDS)[number]
106-
type _NoUnpinnedDomain = [Exclude<CipherstashV3CodecId, _PinnedId>] extends [never]
109+
type _NoUnpinnedDomain = [Exclude<CipherstashV3CodecId, _PinnedId>] extends [
110+
never,
111+
]
107112
? true
108-
: ['catalog gained an unpinned codec id', Exclude<CipherstashV3CodecId, _PinnedId>]
113+
: [
114+
'catalog gained an unpinned codec id',
115+
Exclude<CipherstashV3CodecId, _PinnedId>,
116+
]
109117
const _driftGuard: _NoUnpinnedDomain = true
110118
void _driftGuard
111119

112-
export const CIPHERSTASH_V3_CODEC_ID_SET: ReadonlySet<string> = new Set(CIPHERSTASH_V3_CODEC_IDS)
120+
export const CIPHERSTASH_V3_CODEC_ID_SET: ReadonlySet<string> = new Set(
121+
CIPHERSTASH_V3_CODEC_IDS,
122+
)
113123

114124
export function isCipherstashV3CodecId(id: string): id is CipherstashV3CodecId {
115125
return CIPHERSTASH_V3_CODEC_ID_SET.has(id)
116126
}
117127

118-
export function v3TraitsForCapabilities(caps: QueryCapabilities): readonly string[] {
128+
export function v3TraitsForCapabilities(
129+
caps: QueryCapabilities,
130+
): readonly string[] {
119131
const traits: string[] = []
120132
if (caps.equality) traits.push(CIPHERSTASH_TRAIT_EQUALITY)
121133
if (caps.orderAndRange) traits.push(CIPHERSTASH_TRAIT_ORDER_AND_RANGE)
@@ -124,7 +136,8 @@ export function v3TraitsForCapabilities(caps: QueryCapabilities): readonly strin
124136
return traits
125137
}
126138

127-
export const CIPHERSTASH_V3_BASELINE_MIGRATION_NAME = '20260601T0100_install_eql_v3_bundle'
139+
export const CIPHERSTASH_V3_BASELINE_MIGRATION_NAME =
140+
'20260601T0100_install_eql_v3_bundle'
128141

129142
export const CIPHERSTASH_V3_INVARIANTS = {
130143
installBundle: 'cipherstash:install-eql-v3-bundle-v1',

0 commit comments

Comments
 (0)