Skip to content

Commit c46f2ae

Browse files
calvinbrewerclaude
andcommitted
feat(prisma-next): v3 schema derivation, SDK adapter, runtime descriptor + cipherstashFromStackV3
- deriveStackSchemasV3(contractJson): walks the 0.14 namespace envelope and maps each v3 column's public.eql_v3_* nativeType to its concrete @cipherstash/stack/eql/v3 factory (capabilities are intrinsic to the domain — no typeParams flags), preserving exact domain identity. - createCipherstashV3Sdk(client, schemas): adapts the EncryptionV3 TypedEncryptionClient (structural surface, no casts) to the framework CipherstashSdk. Consumes the Task-6 query-term seam: envelopes marked by markV3QueryTerm route through encryptQuery — one batch crossing per scalar flavour (stack-drizzle's inArray surface), single-call for searchableJson (stack-drizzle's JSON containment path) — while unmarked values take the storage bulkEncrypt path, position-stable. - bulkEncryptMiddlewareV3 now collects pg/text@1-bound marked envelopes (WHERE operands), forwards the envelope itself through the SDK seam, and writes the returned term back as query-term JSONB text without stamping it into the envelope's storage-ciphertext slot. - createCipherstashV3RuntimeDescriptor({ sdk }): the v3 extension descriptor under v3's own id/version (decision 1b — never co-registered with v2; shared method names collide by design). - assertV3SchemasAgree: override validation on EXACT domain identity (integer_ord ≠ integer_ord_ore despite shared cast_as). - cipherstashFromStackV3: the v3-only entry point — hard-errors on v2 cipherstash codec ids and on contracts with no v3 columns; returns { extensions, middleware, encryptionClient } like the v2 factory. - Query-term seam (markV3QueryTerm / v3QueryTermTypeOf + EncryptionOperatorError) extracted to src/v3/query-term.ts so the middleware/adapter don't import the operator registry; operators-v3 re-exports it unchanged. - Exports: v3 surface added to ./runtime and ./stack; new ./v3 subpath (ESM, matching the package's existing ESM-only exports map). - bundling-isolation: allow the v3 catalog chunk (pure domain metadata) as a second cross-plane shared chunk alongside the v2 constants chunk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent faa3976 commit c46f2ae

21 files changed

Lines changed: 2123 additions & 102 deletions

packages/prisma-next/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
"types": "./dist/stack.d.ts",
6161
"import": "./dist/stack.js"
6262
},
63+
"./v3": {
64+
"types": "./dist/v3.d.ts",
65+
"import": "./dist/v3.js"
66+
},
6367
"./package.json": "./package.json"
6468
},
6569
"types": "./dist/control.d.ts",

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,47 @@ export {
120120
CIPHERSTASH_DOUBLE_CODEC_ID,
121121
CIPHERSTASH_JSON_CODEC_ID,
122122
} from '../extension-metadata/constants'
123+
// ---------------------------------------------------------------------------
124+
// EQL v3 runtime surface (decision 1b: a SEPARATE extension descriptor
125+
// under v3's own id — never co-register it with the v2 descriptor
126+
// above; the shared `cipherstash*` method names collide in the flat
127+
// OperationRegistry).
128+
// ---------------------------------------------------------------------------
129+
export {
130+
CIPHERSTASH_V3_CODEC_IDS,
131+
CIPHERSTASH_V3_EXTENSION_VERSION,
132+
CIPHERSTASH_V3_SPACE_ID,
133+
type CipherstashV3CodecId,
134+
isCipherstashV3CodecId,
135+
} from '../extension-metadata/constants-v3'
136+
export { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3'
137+
export {
138+
CipherstashV3CellCodec,
139+
type CipherstashV3CodecParams,
140+
cipherstashV3ParamsSchema,
141+
createV3CodecDescriptors,
142+
} from '../v3/codec-runtime-v3'
143+
export type {
144+
EncryptedNumberFromInternalArgs,
145+
EncryptedNumberHandle,
146+
} from '../v3/envelope-number'
147+
export { EncryptedNumber } from '../v3/envelope-number'
148+
export {
149+
cipherstashV3Asc,
150+
cipherstashV3Desc,
151+
cipherstashV3QueryOperations,
152+
} from '../v3/operators-v3'
153+
export {
154+
EncryptionOperatorError,
155+
markV3QueryTerm,
156+
type V3QueryTermType,
157+
v3QueryTermTypeOf,
158+
} from '../v3/query-term'
159+
export {
160+
type CreateCipherstashV3RuntimeDescriptorOptions,
161+
createCipherstashV3RuntimeDescriptor,
162+
} from '../v3/runtime-v3'
163+
export { v3FromDriver, v3ToDriver } from '../v3/wire-v3'
123164

124165
export { CIPHERSTASH_EXTENSION_VERSION }
125166

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,25 @@ export type {
3232
CipherstashFromStackResult,
3333
} from '../stack/from-stack'
3434
export { cipherstashFromStack } from '../stack/from-stack'
35-
35+
// ---------------------------------------------------------------------------
36+
// EQL v3 (decision 1b: a SEPARATE entry point — a client is v2 or v3,
37+
// never both; `cipherstashFromStackV3` rejects contracts carrying v2
38+
// cipherstash codec ids).
39+
// ---------------------------------------------------------------------------
40+
export type {
41+
CipherstashFromStackV3Options,
42+
CipherstashFromStackV3Result,
43+
} from '../stack/from-stack-v3'
44+
export { cipherstashFromStackV3 } from '../stack/from-stack-v3'
3645
export { createCipherstashSdk } from '../stack/sdk-adapter'
46+
export type {
47+
V3ContractColumnEntry,
48+
V3ContractShape,
49+
} from '../v3/derive-schemas-v3'
50+
export {
51+
deriveStackSchemasV3,
52+
v3ContractColumnEntries,
53+
} from '../v3/derive-schemas-v3'
54+
export { assertV3SchemasAgree } from '../v3/from-stack-v3-validate'
55+
export type { CipherstashV3Client } from '../v3/sdk-adapter-v3'
56+
export { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* `@cipherstash/prisma-next/v3` — the complete EQL v3 surface in one
3+
* import (decision 1b: v3 is a SEPARATE entry point from the v2
4+
* `./runtime` / `./stack` composition — a client is v2 or v3, never
5+
* both; the two runtime descriptors collide if co-registered).
6+
*
7+
* Most consumers want exactly one symbol from here:
8+
*
9+
* const cipherstash = await cipherstashFromStackV3({ contractJson })
10+
*
11+
* The remaining exports are the primitives that factory composes —
12+
* schema derivation, the SDK adapter, the runtime descriptor, the
13+
* bulk-encrypt middleware, envelopes, and the query-term seam — for
14+
* advanced users interposing custom logic (multi-tenant SDK routing,
15+
* a non-stack `CipherstashSdk` implementation, …).
16+
*
17+
* Bundle note: this entry re-exports `cipherstashFromStackV3`, which
18+
* imports `@cipherstash/stack`. Consumers who need an SDK-free runtime
19+
* plane (custom `CipherstashSdk`) should import from `./runtime`
20+
* instead — its v3 exports carry no `@cipherstash/stack` client
21+
* import-edge.
22+
*/
23+
24+
// v3's extension identity + the pinned codec-id union.
25+
export {
26+
CIPHERSTASH_V3_CODEC_IDS,
27+
CIPHERSTASH_V3_EXTENSION_VERSION,
28+
CIPHERSTASH_V3_SPACE_ID,
29+
type CipherstashV3CodecId,
30+
isCipherstashV3CodecId,
31+
} from '../extension-metadata/constants-v3'
32+
// The v3-only entry point + composition primitives.
33+
export type {
34+
CipherstashFromStackV3Options,
35+
CipherstashFromStackV3Result,
36+
} from '../stack/from-stack-v3'
37+
export { cipherstashFromStackV3 } from '../stack/from-stack-v3'
38+
// User-facing value envelopes (version-neutral classes + the v3-only
39+
// EncryptedNumber).
40+
export * from '../v3/barrel'
41+
export { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3'
42+
export {
43+
CipherstashV3CellCodec,
44+
type CipherstashV3CodecParams,
45+
cipherstashV3ParamsSchema,
46+
createV3CodecDescriptors,
47+
} from '../v3/codec-runtime-v3'
48+
export type {
49+
V3ContractColumnEntry,
50+
V3ContractShape,
51+
} from '../v3/derive-schemas-v3'
52+
export {
53+
deriveStackSchemasV3,
54+
v3ContractColumnEntries,
55+
} from '../v3/derive-schemas-v3'
56+
export { assertV3SchemasAgree } from '../v3/from-stack-v3-validate'
57+
// Query operators (registered by the runtime descriptor) + the
58+
// free-standing ORDER BY helpers.
59+
export {
60+
cipherstashV3Asc,
61+
cipherstashV3Desc,
62+
cipherstashV3QueryOperations,
63+
} from '../v3/operators-v3'
64+
// The query-term seam (consumed by custom CipherstashSdk implementations).
65+
export {
66+
EncryptionOperatorError,
67+
markV3QueryTerm,
68+
type V3QueryTermType,
69+
v3QueryTermTypeOf,
70+
} from '../v3/query-term'
71+
export {
72+
type CreateCipherstashV3RuntimeDescriptorOptions,
73+
createCipherstashV3RuntimeDescriptor,
74+
} from '../v3/runtime-v3'
75+
export type { CipherstashV3Client } from '../v3/sdk-adapter-v3'
76+
export { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3'
77+
// v3 wire helpers (plain JSONB).
78+
export { v3FromDriver, v3ToDriver } from '../v3/wire-v3'
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* One-call setup for `@cipherstash/prisma-next` against the
3+
* `@cipherstash/stack` EQL v3 client — the v3-only sibling of
4+
* `./from-stack.ts` (decision 1b: v2 and v3 are SEPARATE entry points
5+
* that are never co-registered in one client; the v2
6+
* `cipherstashFromStack` is untouched by this module).
7+
*
8+
* const cipherstash = await cipherstashFromStackV3({ contractJson })
9+
*
10+
* const db = postgres<Contract>({
11+
* contractJson,
12+
* extensions: cipherstash.extensions,
13+
* middleware: cipherstash.middleware,
14+
* })
15+
*
16+
* A v3 client is v3-only: a contract carrying a v2 cipherstash codec id
17+
* is the WRONG entry point (mixed v2+v3 columns in one client are
18+
* unsupported this release) and is a hard error rather than a silently
19+
* ignored column.
20+
*
21+
* Override semantics: a user-supplied `schemasV3` array is allowed to
22+
* add tables the contract doesn't model. For tables the contract
23+
* **does** declare, the override must agree on EXACT domain identity
24+
* per column (`integer_ord` ≠ `integer_ord_ore` even though both are
25+
* `cast_as: number`) — divergence throws at setup so ZeroKMS can't end
26+
* up minting query terms the installed domain's CHECK rejects.
27+
*/
28+
29+
import type { AnyV3Table } from '@cipherstash/stack/eql/v3'
30+
import type { ClientConfig } from '@cipherstash/stack/types'
31+
import { EncryptionV3, type TypedEncryptionClient } from '@cipherstash/stack/v3'
32+
import type {
33+
SqlMiddleware,
34+
SqlRuntimeExtensionDescriptor,
35+
} from '@prisma-next/sql-runtime'
36+
37+
import { isCipherstashV3CodecId } from '../extension-metadata/constants-v3'
38+
import { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3'
39+
import {
40+
deriveStackSchemasV3,
41+
type V3ContractShape,
42+
v3ContractColumnEntries,
43+
} from '../v3/derive-schemas-v3'
44+
import { assertV3SchemasAgree } from '../v3/from-stack-v3-validate'
45+
import { createCipherstashV3RuntimeDescriptor } from '../v3/runtime-v3'
46+
import { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3'
47+
48+
export interface CipherstashFromStackV3Options {
49+
/** The contract.json artefact emitted by `prisma-next contract emit`. */
50+
readonly contractJson: V3ContractShape
51+
52+
/**
53+
* Optional schema override. Use this to add tables the contract does
54+
* not model. For tables the contract **does** declare, the override
55+
* must match on column names and EXACT `public.eql_v3_*` domain
56+
* identity — divergence throws at setup.
57+
*/
58+
readonly schemasV3?: ReadonlyArray<AnyV3Table>
59+
60+
/** Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). */
61+
readonly encryptionConfig?: ClientConfig
62+
}
63+
64+
export interface CipherstashFromStackV3Result {
65+
/** Ready to spread into `postgres<Contract>({ extensions })`. */
66+
readonly extensions: ReadonlyArray<SqlRuntimeExtensionDescriptor<'postgres'>>
67+
/** Ready to spread into `postgres<Contract>({ middleware })`. */
68+
readonly middleware: ReadonlyArray<SqlMiddleware>
69+
/**
70+
* The initialised v3 `TypedEncryptionClient` for direct SDK access
71+
* outside the ORM path (`encryptModel`, `encryptQuery`, …).
72+
*/
73+
readonly encryptionClient: TypedEncryptionClient<readonly AnyV3Table[]>
74+
}
75+
76+
export async function cipherstashFromStackV3(
77+
opts: CipherstashFromStackV3Options,
78+
): Promise<CipherstashFromStackV3Result> {
79+
const foreignIds = collectNonV3CipherstashCodecIds(opts.contractJson)
80+
if (foreignIds.length > 0) {
81+
throw new Error(
82+
`cipherstashFromStackV3: contract.json contains non-v3 cipherstash codec ids [${foreignIds.join(', ')}]. ` +
83+
'A v3 client is v3-only; use cipherstashFromStack for a v2 contract. ' +
84+
'Mixed v2+v3 cipherstash columns in one client are unsupported.',
85+
)
86+
}
87+
88+
const derived = deriveStackSchemasV3(opts.contractJson)
89+
if (derived.length === 0) {
90+
throw new Error(
91+
'cipherstashFromStackV3: no v3 cipherstash columns found in contract.json. ' +
92+
'Declare at least one v3 `cipherstash.Encrypted*()` column in prisma/schema.prisma and re-emit the contract ' +
93+
'(or use cipherstashFromStack if this is a v2 contract).',
94+
)
95+
}
96+
97+
const schemas = resolveV3Schemas(derived, opts.schemasV3)
98+
99+
const encryptionClient = await EncryptionV3({
100+
schemas,
101+
...(opts.encryptionConfig !== undefined
102+
? { config: opts.encryptionConfig }
103+
: {}),
104+
})
105+
106+
const sdk = createCipherstashV3Sdk(encryptionClient, schemas)
107+
108+
return {
109+
extensions: [createCipherstashV3RuntimeDescriptor({ sdk })],
110+
middleware: [bulkEncryptMiddlewareV3(sdk)],
111+
encryptionClient,
112+
}
113+
}
114+
115+
/**
116+
* Every `cipherstash/*` codec id in the contract that is NOT a member
117+
* of the pinned v3 set — i.e. v2 (or unknown-generation) cipherstash
118+
* columns, which make the contract the wrong input for the v3 entry
119+
* point.
120+
*/
121+
function collectNonV3CipherstashCodecIds(
122+
contractJson: V3ContractShape,
123+
): string[] {
124+
const ids = new Set<string>()
125+
for (const { codecId } of v3ContractColumnEntries(contractJson)) {
126+
if (
127+
typeof codecId === 'string' &&
128+
codecId.startsWith('cipherstash/') &&
129+
!isCipherstashV3CodecId(codecId)
130+
) {
131+
ids.add(codecId)
132+
}
133+
}
134+
return [...ids].sort()
135+
}
136+
137+
/**
138+
* Validate contract-declared tables against their overrides (exact
139+
* domain identity) and append override-only tables — same merge
140+
* semantics as the v2 `resolveSchemas`.
141+
*/
142+
function resolveV3Schemas(
143+
derived: ReadonlyArray<AnyV3Table>,
144+
override: ReadonlyArray<AnyV3Table> | undefined,
145+
): ReadonlyArray<AnyV3Table> {
146+
if (override === undefined || override.length === 0) return derived
147+
148+
const derivedByName = new Map(derived.map((t) => [t.tableName, t]))
149+
const overrideByName = new Map(override.map((t) => [t.tableName, t]))
150+
151+
for (const [tableName, derivedTable] of derivedByName) {
152+
const overrideTable = overrideByName.get(tableName)
153+
if (overrideTable === undefined) continue
154+
assertV3SchemasAgree(derivedTable, overrideTable)
155+
}
156+
157+
return [
158+
...derived,
159+
...override.filter((t) => !derivedByName.has(t.tableName)),
160+
]
161+
}

packages/prisma-next/src/stack/from-stack.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
* `cast_as`, and the installed index set — divergence throws at
2323
* setup so ZeroKMS can't end up with an index set that the EQL
2424
* bundle's installed configuration disagrees with.
25+
*
26+
* This is the **EQL v2** entry point. For an EQL v3 contract
27+
* (`cipherstash/eql-v3/*` codec ids) use `cipherstashFromStackV3`
28+
* (`./from-stack-v3.ts`) — v2 and v3 are separate entry points that
29+
* are never co-registered in one client (decision 1b).
2530
*/
2631

2732
import { Encryption } from '@cipherstash/stack'

0 commit comments

Comments
 (0)