Skip to content

Commit 37c31ae

Browse files
calvinbrewerclaude
authored andcommitted
fix(prisma-next): wire the v3 ORM surface end-to-end
Gaps surfaced by converting the example app and driving the real CLI + runtime rather than unit fixtures: - CodecTypes/QueryOperationTypes cover all 40 v3 codec ids with trait-accurate operator visibility (cipherstash:v3-* marker traits; cipherstashJsonContains v3-only; storage-only domains surface no operator methods at the type level). - The v3 runtime descriptor presents the pack id ('cipherstash') with v3's own version — the runtime matches contract extensionPacks by descriptor id, so the old distinct id failed startup with RUNTIME.MISSING_EXTENSION_PACK. - Every v3 codec id registers a control-plane expandNativeType hook stripping the public. qualifier (the planner requires the hook for typeParams-carrying columns; bare names match introspected udt_name). No onFieldEvent — v3 emits zero add_search_config ops. - The v3 bundle op is reclassified 'data': the integrity checker rejects self-edges without a data-class op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 459b339 commit 37c31ae

15 files changed

Lines changed: 664 additions & 30 deletions

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@
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.
1616
- 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.
1717
- 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.
18+
- **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 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`.
21+
- 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.
22+
- 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/migrations/20260601T0100_install_eql_v3_bundle/migration.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
"cipherstash:install-eql-v3-bundle-v1"
66
],
77
"createdAt": "2026-07-14T20:10:24.325Z",
8-
"migrationHash": "sha256:49917bcf99c88546e4edef9a6323a56045ea522313ee460674e0cc90d747b2c3"
8+
"migrationHash": "sha256:d1ca5a987ab10eb85c2fe05700134b31f17f694d2d5b3df3969e902610b1d9cf"
99
}

packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/migration.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,19 @@ export default class M extends Migration {
5151
rawSql({
5252
id: 'cipherstash.install-eql-v3-bundle',
5353
label: INSTALL_LABEL,
54-
operationClass: 'additive',
54+
// `data`, not `additive`: this edge is a SELF-EDGE (`from ===
55+
// to` — the bundle declares no contract-space storage), and the
56+
// aggregate integrity checker (`migration-tools`
57+
// `check-integrity.ts`) rejects a self-edge unless it carries a
58+
// `data`-class op. Along the axis the checker classifies —
59+
// does the op move the modeled contract shape? — `data` is the
60+
// truthful answer: the bundle creates `public.eql_v3_*` domains
61+
// and `eql_v3.*` functions the space contract deliberately does
62+
// not model. (Contrast the v2 bundle op: `additive`, because
63+
// its edge really moves the hash by adding the
64+
// `eql_v2_configuration` table.) The `migrate` policy allows
65+
// all four classes, so apply behaviour is unchanged.
66+
operationClass: 'data',
5567
invariantId: CIPHERSTASH_V3_INVARIANTS.installBundle,
5668
target: { id: 'postgres' },
5769
precheck: [],

packages/prisma-next/migrations/20260601T0100_install_eql_v3_bundle/ops.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"id": "cipherstash.install-eql-v3-bundle",
44
"label": "Install EQL v3 bundle (eql-3.0.0: public.eql_v3_* domains + eql_v3.* functions)",
5-
"operationClass": "additive",
5+
"operationClass": "data",
66
"invariantId": "cipherstash:install-eql-v3-bundle-v1",
77
"target": {
88
"id": "postgres"

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
cipherstashJsonCodecHooks,
6666
cipherstashStringCodecHooks,
6767
} from '../migration/cipherstash-codec'
68+
import { cipherstashV3CodecControlHooks } from '../migration/cipherstash-codec-v3'
6869

6970
const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
7071
contractJson,
@@ -76,9 +77,11 @@ const cipherstashContractSpace = contractSpaceFromJson<Contract<SqlStorage>>({
7677
},
7778
// The v3 bundle baseline — an invariant-only edge (`from === to`;
7879
// the bundle creates `public.eql_v3_*` domains + `eql_v3.*`
79-
// functions but no contract-space storage). NO v3 codec id is
80-
// registered in `controlPlaneHooks` below: that omission is what
81-
// guarantees v3 columns emit no `add_search_config` /
80+
// functions but no contract-space storage). The v3 codec ids ARE
81+
// registered in `controlPlaneHooks` below, but with the identity
82+
// `expandNativeType` ONLY (the planner requires the hook to exist
83+
// for `typeParams`-carrying columns) — no `onFieldEvent`, which is
84+
// what guarantees v3 columns emit no `add_search_config` /
8285
// `remove_search_config` ops.
8386
{
8487
dirName: CIPHERSTASH_V3_BASELINE_MIGRATION_NAME,
@@ -111,6 +114,9 @@ const cipherstashExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'>
111114
codecTypes: {
112115
...cipherstashPackMeta.types.codecTypes,
113116
controlPlaneHooks: {
117+
// v3: identity expandNativeType only, no onFieldEvent — see
118+
// `../migration/cipherstash-codec-v3.ts`.
119+
...cipherstashV3CodecControlHooks,
114120
[CIPHERSTASH_STRING_CODEC_ID]: cipherstashStringCodecHooks,
115121
[CIPHERSTASH_DOUBLE_CODEC_ID]: cipherstashDoubleCodecHooks,
116122
[CIPHERSTASH_BIGINT_CODEC_ID]: cipherstashBigIntCodecHooks,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Control-plane hooks for the EQL v3 codecs — a schema-stripping
3+
* `expandNativeType` only, NO `onFieldEvent`.
4+
*
5+
* ## Why the hook must exist
6+
*
7+
* The Postgres planner requires an `expandNativeType` hook to EXIST for
8+
* any column that carries `typeParams` (`expandParameterizedTypeSql` in
9+
* target-postgres's `planner-ddl-builders.ts` throws otherwise), and
10+
* every v3 column carries the static `{ castAs, capabilities }`
11+
* typeParams block that v3 authoring emits.
12+
*
13+
* ## Why it strips `public.` instead of the v2 identity expansion
14+
*
15+
* The v3 contract `nativeType` is the schema-qualified domain
16+
* (`public.eql_v3_bigint_ord`), and BOTH downstream consumers of this
17+
* hook want the bare name:
18+
*
19+
* - **DDL** (`buildColumnTypeSql`): an identity return means "no
20+
* expansion" and falls through to `assertSafeNativeType`, whose
21+
* `/^[a-zA-Z][a-zA-Z0-9_ ]*(\[\])?$/` pattern rejects the dot. The
22+
* stripped name renders `CREATE TABLE ... "x" eql_v3_bigint_ord`,
23+
* which resolves to the `public` schema via the search path — the
24+
* bundle creates every v3 domain in `public` by design.
25+
* - **Schema verify** (`renderExpectedNativeType`): the expected type
26+
* is compared against the introspected column's `udt_name`, which
27+
* Postgres reports UNQUALIFIED (`eql_v3_bigint_ord`). The stripped
28+
* name is exactly that spelling, so post-apply verification and
29+
* re-plan diffs see the column as clean.
30+
*
31+
* ## Deliberately NO `onFieldEvent`
32+
*
33+
* v3 needs no per-column search configuration (no
34+
* `eql_v2.add_search_config` analogue exists — the domain itself
35+
* carries the index metadata), so added/dropped/altered v3 columns
36+
* contribute zero extra migration ops. `test/v3/migration-v3.test.ts`
37+
* and the example app's e2e suite pin the absence of search-config ops
38+
* end-to-end.
39+
*/
40+
41+
import type { CodecControlHooks } from '@prisma-next/family-sql/control'
42+
import {
43+
CIPHERSTASH_V3_CODEC_IDS,
44+
type CipherstashV3CodecId,
45+
} from '../extension-metadata/constants-v3'
46+
47+
const PUBLIC_SCHEMA_PREFIX = 'public.'
48+
49+
const stripPublicSchema: NonNullable<CodecControlHooks['expandNativeType']> = ({
50+
nativeType,
51+
}) =>
52+
nativeType.startsWith(PUBLIC_SCHEMA_PREFIX)
53+
? nativeType.slice(PUBLIC_SCHEMA_PREFIX.length)
54+
: nativeType
55+
56+
const v3Hooks: CodecControlHooks = { expandNativeType: stripPublicSchema }
57+
58+
/**
59+
* One hooks entry per pinned v3 codec id, all sharing the
60+
* schema-stripping `expandNativeType`. Spread into the control
61+
* descriptor's `types.codecTypes.controlPlaneHooks` alongside the six
62+
* v2 entries.
63+
*/
64+
export const cipherstashV3CodecControlHooks: Readonly<
65+
Record<CipherstashV3CodecId, CodecControlHooks>
66+
> = Object.fromEntries(
67+
CIPHERSTASH_V3_CODEC_IDS.map((codecId) => [codecId, v3Hooks]),
68+
// Object.fromEntries widens keys to `string`; the entries are exactly
69+
// the pinned `CIPHERSTASH_V3_CODEC_IDS` tuple, so the record type
70+
// holds by construction.
71+
) as Record<CipherstashV3CodecId, CodecControlHooks>

0 commit comments

Comments
 (0)